diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7cbb1a1983..4dfd7596d1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,26 +27,12 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- # build_npm_package.py requires DotSlash when staging releases.
- - uses: facebook/install-dotslash@v2
+ # Run all tasks using workspace filters
- - name: Stage npm package
+ - name: Ensure staging a release works.
env:
GH_TOKEN: ${{ github.token }}
- run: |
- set -euo pipefail
- CODEX_VERSION=0.40.0
- PACK_OUTPUT="${RUNNER_TEMP}/codex-npm.tgz"
- python3 ./codex-cli/scripts/build_npm_package.py \
- --release-version "$CODEX_VERSION" \
- --pack-output "$PACK_OUTPUT"
- echo "PACK_OUTPUT=$PACK_OUTPUT" >> "$GITHUB_ENV"
-
- - name: Upload staged npm package artifact
- uses: actions/upload-artifact@v4
- with:
- name: codex-npm-staging
- path: ${{ env.PACK_OUTPUT }}
+ run: ./codex-cli/scripts/stage_release.sh
- name: Ensure root README.md contains only ASCII and certain Unicode code points
run: ./scripts/asciicheck.py README.md
diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml
index 846408f3a8..280939c611 100644
--- a/.github/workflows/rust-ci.yml
+++ b/.github/workflows/rust-ci.yml
@@ -57,7 +57,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v5
- - uses: dtolnay/rust-toolchain@1.90
+ - uses: dtolnay/rust-toolchain@1.89
with:
components: rustfmt
- name: cargo fmt
@@ -75,7 +75,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v5
- - uses: dtolnay/rust-toolchain@1.90
+ - uses: dtolnay/rust-toolchain@1.89
- uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
with:
tool: cargo-shear
@@ -143,7 +143,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- - uses: dtolnay/rust-toolchain@1.90
+ - uses: dtolnay/rust-toolchain@1.89
with:
targets: ${{ matrix.target }}
components: clippy
diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml
index c808216d96..e3776f149c 100644
--- a/.github/workflows/rust-release.yml
+++ b/.github/workflows/rust-release.yml
@@ -77,7 +77,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- - uses: dtolnay/rust-toolchain@1.90
+ - uses: dtolnay/rust-toolchain@1.89
with:
targets: ${{ matrix.target }}
@@ -173,8 +173,6 @@ jobs:
outputs:
version: ${{ steps.release_name.outputs.name }}
tag: ${{ github.ref_name }}
- should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
- npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
steps:
- name: Checkout repository
@@ -195,37 +193,21 @@ jobs:
version="${GITHUB_REF_NAME#rust-v}"
echo "name=${version}" >> $GITHUB_OUTPUT
- - name: Determine npm publish settings
- id: npm_publish_settings
- env:
- VERSION: ${{ steps.release_name.outputs.name }}
- run: |
- set -euo pipefail
- version="${VERSION}"
-
- if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "should_publish=true" >> "$GITHUB_OUTPUT"
- echo "npm_tag=" >> "$GITHUB_OUTPUT"
- elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
- echo "should_publish=true" >> "$GITHUB_OUTPUT"
- echo "npm_tag=alpha" >> "$GITHUB_OUTPUT"
- else
- echo "should_publish=false" >> "$GITHUB_OUTPUT"
- echo "npm_tag=" >> "$GITHUB_OUTPUT"
- fi
-
- # build_npm_package.py requires DotSlash when staging releases.
- - uses: facebook/install-dotslash@v2
- name: Stage npm package
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TMP_DIR="${RUNNER_TEMP}/npm-stage"
- ./codex-cli/scripts/build_npm_package.py \
+ python3 codex-cli/scripts/stage_rust_release.py \
--release-version "${{ steps.release_name.outputs.name }}" \
- --staging-dir "${TMP_DIR}" \
- --pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
+ --tmp "${TMP_DIR}"
+ mkdir -p dist/npm
+ # Produce an npm-ready tarball using `npm pack` and store it in dist/npm.
+ # We then rename it to a stable name used by our publishing script.
+ (cd "$TMP_DIR" && npm pack --pack-destination "${GITHUB_WORKSPACE}/dist/npm")
+ mv "${GITHUB_WORKSPACE}"/dist/npm/*.tgz \
+ "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -248,8 +230,8 @@ jobs:
# July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/
# npm docs: https://docs.npmjs.com/trusted-publishers
publish-npm:
- # Publish to npm for stable releases and alpha pre-releases with numeric suffixes.
- if: ${{ needs.release.outputs.should_publish_npm == 'true' }}
+ # Skip this step for pre-releases (alpha/beta).
+ if: ${{ !contains(needs.release.outputs.version, '-') }}
name: publish-npm
needs: release
runs-on: ubuntu-latest
@@ -284,17 +266,7 @@ jobs:
# No NODE_AUTH_TOKEN needed because we use OIDC.
- name: Publish to npm
- env:
- VERSION: ${{ needs.release.outputs.version }}
- NPM_TAG: ${{ needs.release.outputs.npm_tag }}
- run: |
- set -euo pipefail
- tag_args=()
- if [[ -n "${NPM_TAG}" ]]; then
- tag_args+=(--tag "${NPM_TAG}")
- fi
-
- npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${VERSION}.tgz" "${tag_args[@]}"
+ run: npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ needs.release.outputs.version }}.tgz"
update-branch:
name: Update latest-alpha-cli branch
diff --git a/codex-cli/.gitignore b/codex-cli/.gitignore
index 57872d0f1e..f886e64f46 100644
--- a/codex-cli/.gitignore
+++ b/codex-cli/.gitignore
@@ -1 +1,7 @@
-/vendor/
+# Added by ./scripts/install_native_deps.sh
+/bin/codex-aarch64-apple-darwin
+/bin/codex-aarch64-unknown-linux-musl
+/bin/codex-linux-sandbox-arm64
+/bin/codex-linux-sandbox-x64
+/bin/codex-x86_64-apple-darwin
+/bin/codex-x86_64-unknown-linux-musl
diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js
index 3621f48c12..f24065170d 100755
--- a/codex-cli/bin/codex.js
+++ b/codex-cli/bin/codex.js
@@ -1,7 +1,6 @@
#!/usr/bin/env node
// Unified entry point for the Codex CLI.
-import { existsSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
@@ -41,10 +40,10 @@ switch (platform) {
case "win32":
switch (arch) {
case "x64":
- targetTriple = "x86_64-pc-windows-msvc";
+ targetTriple = "x86_64-pc-windows-msvc.exe";
break;
case "arm64":
- targetTriple = "aarch64-pc-windows-msvc";
+ targetTriple = "aarch64-pc-windows-msvc.exe";
break;
default:
break;
@@ -58,10 +57,7 @@ if (!targetTriple) {
throw new Error(`Unsupported platform: ${platform} (${arch})`);
}
-const vendorRoot = path.join(__dirname, "..", "vendor");
-const archRoot = path.join(vendorRoot, targetTriple);
-const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
-const binaryPath = path.join(archRoot, "codex", codexBinaryName);
+const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
// Use an asynchronous spawn instead of spawnSync so that Node is able to
// respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is
@@ -70,6 +66,23 @@ const binaryPath = path.join(archRoot, "codex", codexBinaryName);
// receives a fatal signal, both processes exit in a predictable manner.
const { spawn } = await import("child_process");
+async function tryImport(moduleName) {
+ try {
+ // eslint-disable-next-line node/no-unsupported-features/es-syntax
+ return await import(moduleName);
+ } catch (err) {
+ return null;
+ }
+}
+
+async function resolveRgDir() {
+ const ripgrep = await tryImport("@vscode/ripgrep");
+ if (!ripgrep?.rgPath) {
+ return null;
+ }
+ return path.dirname(ripgrep.rgPath);
+}
+
function getUpdatedPath(newDirs) {
const pathSep = process.platform === "win32" ? ";" : ":";
const existingPath = process.env.PATH || "";
@@ -81,9 +94,9 @@ function getUpdatedPath(newDirs) {
}
const additionalDirs = [];
-const pathDir = path.join(archRoot, "path");
-if (existsSync(pathDir)) {
- additionalDirs.push(pathDir);
+const rgDir = await resolveRgDir();
+if (rgDir) {
+ additionalDirs.push(rgDir);
}
const updatedPath = getUpdatedPath(additionalDirs);
diff --git a/codex-cli/bin/rg b/codex-cli/bin/rg
deleted file mode 100755
index 5a992570a9..0000000000
--- a/codex-cli/bin/rg
+++ /dev/null
@@ -1,79 +0,0 @@
-#!/usr/bin/env dotslash
-
-{
- "name": "rg",
- "platforms": {
- "macos-aarch64": {
- "size": 1787248,
- "hash": "blake3",
- "digest": "8d9942032585ea8ee805937634238d9aee7b210069f4703c88fbe568e26fb78a",
- "format": "tar.gz",
- "path": "ripgrep-14.1.1-aarch64-apple-darwin/rg",
- "providers": [
- {
- "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz"
- }
- ]
- },
- "linux-aarch64": {
- "size": 2047405,
- "hash": "blake3",
- "digest": "0b670b8fa0a3df2762af2fc82cc4932f684ca4c02dbd1260d4f3133fd4b2a515",
- "format": "tar.gz",
- "path": "ripgrep-14.1.1-aarch64-unknown-linux-gnu/rg",
- "providers": [
- {
- "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz"
- }
- ]
- },
- "macos-x86_64": {
- "size": 2082672,
- "hash": "blake3",
- "digest": "e9b862fc8da3127f92791f0ff6a799504154ca9d36c98bf3e60a81c6b1f7289e",
- "format": "tar.gz",
- "path": "ripgrep-14.1.1-x86_64-apple-darwin/rg",
- "providers": [
- {
- "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-apple-darwin.tar.gz"
- }
- ]
- },
- "linux-x86_64": {
- "size": 2566310,
- "hash": "blake3",
- "digest": "f73cca4e54d78c31f832c7f6e2c0b4db8b04fa3eaa747915727d570893dbee76",
- "format": "tar.gz",
- "path": "ripgrep-14.1.1-x86_64-unknown-linux-musl/rg",
- "providers": [
- {
- "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz"
- }
- ]
- },
- "windows-x86_64": {
- "size": 2058893,
- "hash": "blake3",
- "digest": "a8ce1a6fed4f8093ee997e57f33254e94b2cd18e26358b09db599c89882eadbd",
- "format": "zip",
- "path": "ripgrep-14.1.1-x86_64-pc-windows-msvc/rg.exe",
- "providers": [
- {
- "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-pc-windows-msvc.zip"
- }
- ]
- },
- "windows-aarch64": {
- "size": 1667740,
- "hash": "blake3",
- "digest": "47b971a8c4fca1d23a4e7c19bd4d88465ebc395598458133139406d3bf85f3fa",
- "format": "zip",
- "path": "rg.exe",
- "providers": [
- {
- "url": "https://github.com/microsoft/ripgrep-prebuilt/releases/download/v13.0.0-13/ripgrep-v13.0.0-13-aarch64-pc-windows-msvc.zip"
- }
- ]
- }
- }
-}
diff --git a/codex-cli/package-lock.json b/codex-cli/package-lock.json
index a28bb9cd9f..a1c840ade0 100644
--- a/codex-cli/package-lock.json
+++ b/codex-cli/package-lock.json
@@ -2,17 +2,118 @@
"name": "@openai/codex",
"version": "0.0.0-dev",
"lockfileVersion": 3,
+ "requires": true,
"packages": {
"": {
"name": "@openai/codex",
"version": "0.0.0-dev",
"license": "Apache-2.0",
+ "dependencies": {
+ "@vscode/ripgrep": "^1.15.14"
+ },
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=20"
}
+ },
+ "node_modules/@vscode/ripgrep": {
+ "version": "1.15.14",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.14.tgz",
+ "integrity": "sha512-/G1UJPYlm+trBWQ6cMO3sv6b8D1+G16WaJH1/DSqw32JOVlzgZbLkDxRyzIpTpv30AcYGMkCf5tUqGlW6HbDWw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "https-proxy-agent": "^7.0.2",
+ "proxy-from-env": "^1.1.0",
+ "yauzl": "^2.9.2"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
}
}
}
diff --git a/codex-cli/package.json b/codex-cli/package.json
index 03f234d677..02124f3257 100644
--- a/codex-cli/package.json
+++ b/codex-cli/package.json
@@ -11,11 +11,17 @@
},
"files": [
"bin",
- "vendor"
+ "dist"
],
"repository": {
"type": "git",
"url": "git+https://github.com/openai/codex.git",
"directory": "codex-cli"
+ },
+ "dependencies": {
+ "@vscode/ripgrep": "^1.15.14"
+ },
+ "devDependencies": {
+ "prettier": "^3.3.3"
}
}
diff --git a/codex-cli/scripts/README.md b/codex-cli/scripts/README.md
index fd2265b299..21e4f3e883 100644
--- a/codex-cli/scripts/README.md
+++ b/codex-cli/scripts/README.md
@@ -5,7 +5,5 @@ Run the following:
To build the 0.2.x or later version of the npm module, which runs the Rust version of the CLI, build it as follows:
```bash
-./codex-cli/scripts/build_npm_package.py --release-version 0.6.0
+./codex-cli/scripts/stage_rust_release.py --release-version 0.6.0
```
-
-Note this will create `./codex-cli/vendor/` as a side-effect.
diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py
deleted file mode 100755
index 0401f10385..0000000000
--- a/codex-cli/scripts/build_npm_package.py
+++ /dev/null
@@ -1,269 +0,0 @@
-#!/usr/bin/env python3
-"""Stage and optionally package the @openai/codex npm module."""
-
-import argparse
-import json
-import re
-import shutil
-import subprocess
-import sys
-import tempfile
-from pathlib import Path
-
-SCRIPT_DIR = Path(__file__).resolve().parent
-CODEX_CLI_ROOT = SCRIPT_DIR.parent
-REPO_ROOT = CODEX_CLI_ROOT.parent
-GITHUB_REPO = "openai/codex"
-
-# The docs are not clear on what the expected value/format of
-# workflow/workflowName is:
-# https://cli.github.com/manual/gh_run_list
-WORKFLOW_NAME = ".github/workflows/rust-release.yml"
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.")
- parser.add_argument(
- "--version",
- help="Version number to write to package.json inside the staged package.",
- )
- parser.add_argument(
- "--release-version",
- help=(
- "Version to stage for npm release. When provided, the script also resolves the "
- "matching rust-release workflow unless --workflow-url is supplied."
- ),
- )
- parser.add_argument(
- "--workflow-url",
- help="Optional GitHub Actions workflow run URL used to download native binaries.",
- )
- parser.add_argument(
- "--staging-dir",
- type=Path,
- help=(
- "Directory to stage the package contents. Defaults to a new temporary directory "
- "if omitted. The directory must be empty when provided."
- ),
- )
- parser.add_argument(
- "--tmp",
- dest="staging_dir",
- type=Path,
- help=argparse.SUPPRESS,
- )
- parser.add_argument(
- "--pack-output",
- type=Path,
- help="Path where the generated npm tarball should be written.",
- )
- return parser.parse_args()
-
-
-def main() -> int:
- args = parse_args()
-
- version = args.version
- release_version = args.release_version
- if release_version:
- if version and version != release_version:
- raise RuntimeError("--version and --release-version must match when both are provided.")
- version = release_version
-
- if not version:
- raise RuntimeError("Must specify --version or --release-version.")
-
- staging_dir, created_temp = prepare_staging_dir(args.staging_dir)
-
- try:
- stage_sources(staging_dir, version)
-
- workflow_url = args.workflow_url
- resolved_head_sha: str | None = None
- if not workflow_url:
- if release_version:
- workflow = resolve_release_workflow(version)
- workflow_url = workflow["url"]
- resolved_head_sha = workflow.get("headSha")
- else:
- workflow_url = resolve_latest_alpha_workflow_url()
- elif release_version:
- try:
- workflow = resolve_release_workflow(version)
- resolved_head_sha = workflow.get("headSha")
- except Exception:
- resolved_head_sha = None
-
- if release_version and resolved_head_sha:
- print(f"should `git checkout {resolved_head_sha}`")
-
- if not workflow_url:
- raise RuntimeError("Unable to determine workflow URL for native binaries.")
-
- install_native_binaries(staging_dir, workflow_url)
-
- if release_version:
- staging_dir_str = str(staging_dir)
- print(
- f"Staged version {version} for release in {staging_dir_str}\n\n"
- "Verify the CLI:\n"
- f" node {staging_dir_str}/bin/codex.js --version\n"
- f" node {staging_dir_str}/bin/codex.js --help\n\n"
- )
- else:
- print(f"Staged package in {staging_dir}")
-
- if args.pack_output is not None:
- output_path = run_npm_pack(staging_dir, args.pack_output)
- print(f"npm pack output written to {output_path}")
- finally:
- if created_temp:
- # Preserve the staging directory for further inspection.
- pass
-
- return 0
-
-
-def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]:
- if staging_dir is not None:
- staging_dir = staging_dir.resolve()
- staging_dir.mkdir(parents=True, exist_ok=True)
- if any(staging_dir.iterdir()):
- raise RuntimeError(f"Staging directory {staging_dir} is not empty.")
- return staging_dir, False
-
- temp_dir = Path(tempfile.mkdtemp(prefix="codex-npm-stage-"))
- return temp_dir, True
-
-
-def stage_sources(staging_dir: Path, version: str) -> None:
- bin_dir = staging_dir / "bin"
- bin_dir.mkdir(parents=True, exist_ok=True)
-
- shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js")
- rg_manifest = CODEX_CLI_ROOT / "bin" / "rg"
- if rg_manifest.exists():
- shutil.copy2(rg_manifest, bin_dir / "rg")
-
- readme_src = REPO_ROOT / "README.md"
- if readme_src.exists():
- shutil.copy2(readme_src, staging_dir / "README.md")
-
- with open(CODEX_CLI_ROOT / "package.json", "r", encoding="utf-8") as fh:
- package_json = json.load(fh)
- package_json["version"] = version
-
- with open(staging_dir / "package.json", "w", encoding="utf-8") as out:
- json.dump(package_json, out, indent=2)
- out.write("\n")
-
-
-def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None:
- cmd = ["./scripts/install_native_deps.py"]
- if workflow_url:
- cmd.extend(["--workflow-url", workflow_url])
- cmd.append(str(staging_dir))
- subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT)
-
-
-def resolve_latest_alpha_workflow_url() -> str:
- version = determine_latest_alpha_version()
- workflow = resolve_release_workflow(version)
- return workflow["url"]
-
-
-def determine_latest_alpha_version() -> str:
- releases = list_releases()
- best_key: tuple[int, int, int, int] | None = None
- best_version: str | None = None
- pattern = re.compile(r"^rust-v(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$")
- for release in releases:
- tag = release.get("tag_name", "")
- match = pattern.match(tag)
- if not match:
- continue
- key = tuple(int(match.group(i)) for i in range(1, 5))
- if best_key is None or key > best_key:
- best_key = key
- best_version = (
- f"{match.group(1)}.{match.group(2)}.{match.group(3)}-alpha.{match.group(4)}"
- )
-
- if best_version is None:
- raise RuntimeError("No alpha releases found when resolving workflow URL.")
- return best_version
-
-
-def list_releases() -> list[dict]:
- stdout = subprocess.check_output(
- ["gh", "api", f"/repos/{GITHUB_REPO}/releases?per_page=100"],
- text=True,
- )
- try:
- releases = json.loads(stdout or "[]")
- except json.JSONDecodeError as exc:
- raise RuntimeError("Unable to parse releases JSON.") from exc
- if not isinstance(releases, list):
- raise RuntimeError("Unexpected response when listing releases.")
- return releases
-
-
-def resolve_release_workflow(version: str) -> dict:
- stdout = subprocess.check_output(
- [
- "gh",
- "run",
- "list",
- "--branch",
- f"rust-v{version}",
- "--json",
- "workflowName,url,headSha",
- "--workflow",
- WORKFLOW_NAME,
- "--jq",
- "first(.[])",
- ],
- text=True,
- )
- workflow = json.loads(stdout or "[]")
- if not workflow:
- raise RuntimeError(f"Unable to find rust-release workflow for version {version}.")
- return workflow
-
-
-def run_npm_pack(staging_dir: Path, output_path: Path) -> Path:
- output_path = output_path.resolve()
- output_path.parent.mkdir(parents=True, exist_ok=True)
-
- with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str:
- pack_dir = Path(pack_dir_str)
- stdout = subprocess.check_output(
- ["npm", "pack", "--json", "--pack-destination", str(pack_dir)],
- cwd=staging_dir,
- text=True,
- )
- try:
- pack_output = json.loads(stdout)
- except json.JSONDecodeError as exc:
- raise RuntimeError("Failed to parse npm pack output.") from exc
-
- if not pack_output:
- raise RuntimeError("npm pack did not produce an output tarball.")
-
- tarball_name = pack_output[0].get("filename") or pack_output[0].get("name")
- if not tarball_name:
- raise RuntimeError("Unable to determine npm pack output filename.")
-
- tarball_path = pack_dir / tarball_name
- if not tarball_path.exists():
- raise RuntimeError(f"Expected npm pack output not found: {tarball_path}")
-
- shutil.move(str(tarball_path), output_path)
-
- return output_path
-
-
-if __name__ == "__main__":
- import sys
-
- sys.exit(main())
diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py
deleted file mode 100755
index 7fbb44393a..0000000000
--- a/codex-cli/scripts/install_native_deps.py
+++ /dev/null
@@ -1,318 +0,0 @@
-#!/usr/bin/env python3
-"""Install Codex native binaries (Rust CLI plus ripgrep helpers)."""
-
-import argparse
-import json
-import os
-import shutil
-import subprocess
-import tarfile
-import tempfile
-import zipfile
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from pathlib import Path
-from typing import Iterable, Sequence
-from urllib.parse import urlparse
-from urllib.request import urlopen
-
-SCRIPT_DIR = Path(__file__).resolve().parent
-CODEX_CLI_ROOT = SCRIPT_DIR.parent
-DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0
-VENDOR_DIR_NAME = "vendor"
-RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg"
-CODEX_TARGETS = (
- "x86_64-unknown-linux-musl",
- "aarch64-unknown-linux-musl",
- "x86_64-apple-darwin",
- "aarch64-apple-darwin",
- "x86_64-pc-windows-msvc",
- "aarch64-pc-windows-msvc",
-)
-
-RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
- ("x86_64-unknown-linux-musl", "linux-x86_64"),
- ("aarch64-unknown-linux-musl", "linux-aarch64"),
- ("x86_64-apple-darwin", "macos-x86_64"),
- ("aarch64-apple-darwin", "macos-aarch64"),
- ("x86_64-pc-windows-msvc", "windows-x86_64"),
- ("aarch64-pc-windows-msvc", "windows-aarch64"),
-]
-RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS}
-DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS]
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Install native Codex binaries.")
- parser.add_argument(
- "--workflow-url",
- help=(
- "GitHub Actions workflow URL that produced the artifacts. Defaults to a "
- "known good run when omitted."
- ),
- )
- parser.add_argument(
- "root",
- nargs="?",
- type=Path,
- help=(
- "Directory containing package.json for the staged package. If omitted, the "
- "repository checkout is used."
- ),
- )
- return parser.parse_args()
-
-
-def main() -> int:
- args = parse_args()
-
- codex_cli_root = (args.root or CODEX_CLI_ROOT).resolve()
- vendor_dir = codex_cli_root / VENDOR_DIR_NAME
- vendor_dir.mkdir(parents=True, exist_ok=True)
-
- workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
- if not workflow_url:
- workflow_url = DEFAULT_WORKFLOW_URL
-
- workflow_id = workflow_url.rstrip("/").split("/")[-1]
-
- with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str:
- artifacts_dir = Path(artifacts_dir_str)
- _download_artifacts(workflow_id, artifacts_dir)
- install_codex_binaries(artifacts_dir, vendor_dir, CODEX_TARGETS)
-
- fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
-
- print(f"Installed native dependencies into {vendor_dir}")
- return 0
-
-
-def fetch_rg(
- vendor_dir: Path,
- targets: Sequence[str] | None = None,
- *,
- manifest_path: Path,
-) -> list[Path]:
- """Download ripgrep binaries described by the DotSlash manifest."""
-
- if targets is None:
- targets = DEFAULT_RG_TARGETS
-
- if not manifest_path.exists():
- raise FileNotFoundError(f"DotSlash manifest not found: {manifest_path}")
-
- manifest = _load_manifest(manifest_path)
- platforms = manifest.get("platforms", {})
-
- vendor_dir.mkdir(parents=True, exist_ok=True)
-
- targets = list(targets)
- if not targets:
- return []
-
- task_configs: list[tuple[str, str, dict]] = []
- for target in targets:
- platform_key = RG_TARGET_TO_PLATFORM.get(target)
- if platform_key is None:
- raise ValueError(f"Unsupported ripgrep target '{target}'.")
-
- platform_info = platforms.get(platform_key)
- if platform_info is None:
- raise RuntimeError(f"Platform '{platform_key}' not found in manifest {manifest_path}.")
-
- task_configs.append((target, platform_key, platform_info))
-
- results: dict[str, Path] = {}
- max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1)))
-
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
- future_map = {
- executor.submit(
- _fetch_single_rg,
- vendor_dir,
- target,
- platform_key,
- platform_info,
- manifest_path,
- ): target
- for target, platform_key, platform_info in task_configs
- }
-
- for future in as_completed(future_map):
- target = future_map[future]
- results[target] = future.result()
-
- return [results[target] for target in targets]
-
-
-def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
- cmd = [
- "gh",
- "run",
- "download",
- "--dir",
- str(dest_dir),
- "--repo",
- "openai/codex",
- workflow_id,
- ]
- subprocess.check_call(cmd)
-
-
-def install_codex_binaries(
- artifacts_dir: Path, vendor_dir: Path, targets: Iterable[str]
-) -> list[Path]:
- targets = list(targets)
- if not targets:
- return []
-
- results: dict[str, Path] = {}
- max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
-
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
- future_map = {
- executor.submit(_install_single_codex_binary, artifacts_dir, vendor_dir, target): target
- for target in targets
- }
-
- for future in as_completed(future_map):
- target = future_map[future]
- results[target] = future.result()
-
- return [results[target] for target in targets]
-
-
-def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target: str) -> Path:
- artifact_subdir = artifacts_dir / target
- archive_name = _archive_name_for_target(target)
- archive_path = artifact_subdir / archive_name
- if not archive_path.exists():
- raise FileNotFoundError(f"Expected artifact not found: {archive_path}")
-
- dest_dir = vendor_dir / target / "codex"
- dest_dir.mkdir(parents=True, exist_ok=True)
-
- binary_name = "codex.exe" if "windows" in target else "codex"
- dest = dest_dir / binary_name
- dest.unlink(missing_ok=True)
- extract_archive(archive_path, "zst", None, dest)
- if "windows" not in target:
- dest.chmod(0o755)
- return dest
-
-
-def _archive_name_for_target(target: str) -> str:
- if "windows" in target:
- return f"codex-{target}.exe.zst"
- return f"codex-{target}.zst"
-
-
-def _fetch_single_rg(
- vendor_dir: Path,
- target: str,
- platform_key: str,
- platform_info: dict,
- manifest_path: Path,
-) -> Path:
- providers = platform_info.get("providers", [])
- if not providers:
- raise RuntimeError(f"No providers listed for platform '{platform_key}' in {manifest_path}.")
-
- url = providers[0]["url"]
- archive_format = platform_info.get("format", "zst")
- archive_member = platform_info.get("path")
-
- dest_dir = vendor_dir / target / "path"
- dest_dir.mkdir(parents=True, exist_ok=True)
-
- is_windows = platform_key.startswith("win")
- binary_name = "rg.exe" if is_windows else "rg"
- dest = dest_dir / binary_name
-
- with tempfile.TemporaryDirectory() as tmp_dir_str:
- tmp_dir = Path(tmp_dir_str)
- archive_filename = os.path.basename(urlparse(url).path)
- download_path = tmp_dir / archive_filename
- _download_file(url, download_path)
-
- dest.unlink(missing_ok=True)
- extract_archive(download_path, archive_format, archive_member, dest)
-
- if not is_windows:
- dest.chmod(0o755)
-
- return dest
-
-
-def _download_file(url: str, dest: Path) -> None:
- dest.parent.mkdir(parents=True, exist_ok=True)
- with urlopen(url) as response, open(dest, "wb") as out:
- shutil.copyfileobj(response, out)
-
-
-def extract_archive(
- archive_path: Path,
- archive_format: str,
- archive_member: str | None,
- dest: Path,
-) -> None:
- dest.parent.mkdir(parents=True, exist_ok=True)
-
- if archive_format == "zst":
- output_path = archive_path.parent / dest.name
- subprocess.check_call(
- ["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)]
- )
- shutil.move(str(output_path), dest)
- return
-
- if archive_format == "tar.gz":
- if not archive_member:
- raise RuntimeError("Missing 'path' for tar.gz archive in DotSlash manifest.")
- with tarfile.open(archive_path, "r:gz") as tar:
- try:
- member = tar.getmember(archive_member)
- except KeyError as exc:
- raise RuntimeError(
- f"Entry '{archive_member}' not found in archive {archive_path}."
- ) from exc
- tar.extract(member, path=archive_path.parent, filter="data")
- extracted = archive_path.parent / archive_member
- shutil.move(str(extracted), dest)
- return
-
- if archive_format == "zip":
- if not archive_member:
- raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.")
- with zipfile.ZipFile(archive_path) as archive:
- try:
- with archive.open(archive_member) as src, open(dest, "wb") as out:
- shutil.copyfileobj(src, out)
- except KeyError as exc:
- raise RuntimeError(
- f"Entry '{archive_member}' not found in archive {archive_path}."
- ) from exc
- return
-
- raise RuntimeError(f"Unsupported archive format '{archive_format}'.")
-
-
-def _load_manifest(manifest_path: Path) -> dict:
- cmd = ["dotslash", "--", "parse", str(manifest_path)]
- stdout = subprocess.check_output(cmd, text=True)
- try:
- manifest = json.loads(stdout)
- except json.JSONDecodeError as exc:
- raise RuntimeError(f"Invalid DotSlash manifest output from {manifest_path}.") from exc
-
- if not isinstance(manifest, dict):
- raise RuntimeError(
- f"Unexpected DotSlash manifest structure for {manifest_path}: {type(manifest)!r}"
- )
-
- return manifest
-
-
-if __name__ == "__main__":
- import sys
-
- sys.exit(main())
diff --git a/codex-cli/scripts/install_native_deps.sh b/codex-cli/scripts/install_native_deps.sh
new file mode 100755
index 0000000000..b9fda2543e
--- /dev/null
+++ b/codex-cli/scripts/install_native_deps.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+
+# Install native runtime dependencies for codex-cli.
+#
+# Usage
+# install_native_deps.sh [--workflow-url URL] [CODEX_CLI_ROOT]
+#
+# The optional RELEASE_ROOT is the path that contains package.json. Omitting
+# it installs the binaries into the repository's own bin/ folder to support
+# local development.
+
+set -euo pipefail
+
+# ------------------
+# Parse arguments
+# ------------------
+
+CODEX_CLI_ROOT=""
+
+# Until we start publishing stable GitHub releases, we have to grab the binaries
+# from the GitHub Action that created them. Update the URL below to point to the
+# appropriate workflow run:
+WORKFLOW_URL="https://github.com/openai/codex/actions/runs/17417194663" # rust-v0.28.0
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --workflow-url)
+ shift || { echo "--workflow-url requires an argument"; exit 1; }
+ if [ -n "$1" ]; then
+ WORKFLOW_URL="$1"
+ fi
+ ;;
+ *)
+ if [[ -z "$CODEX_CLI_ROOT" ]]; then
+ CODEX_CLI_ROOT="$1"
+ else
+ echo "Unexpected argument: $1" >&2
+ exit 1
+ fi
+ ;;
+ esac
+ shift
+done
+
+# ----------------------------------------------------------------------------
+# Determine where the binaries should be installed.
+# ----------------------------------------------------------------------------
+
+if [ -n "$CODEX_CLI_ROOT" ]; then
+ # The caller supplied a release root directory.
+ BIN_DIR="$CODEX_CLI_ROOT/bin"
+else
+ # No argument; fall back to the repoโs own bin directory.
+ # Resolve the path of this script, then walk up to the repo root.
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+ BIN_DIR="$CODEX_CLI_ROOT/bin"
+fi
+
+# Make sure the destination directory exists.
+mkdir -p "$BIN_DIR"
+
+# ----------------------------------------------------------------------------
+# Download and decompress the artifacts from the GitHub Actions workflow.
+# ----------------------------------------------------------------------------
+
+WORKFLOW_ID="${WORKFLOW_URL##*/}"
+
+ARTIFACTS_DIR="$(mktemp -d)"
+trap 'rm -rf "$ARTIFACTS_DIR"' EXIT
+
+# NB: The GitHub CLI `gh` must be installed and authenticated.
+gh run download --dir "$ARTIFACTS_DIR" --repo openai/codex "$WORKFLOW_ID"
+
+# x64 Linux
+zstd -d "$ARTIFACTS_DIR/x86_64-unknown-linux-musl/codex-x86_64-unknown-linux-musl.zst" \
+ -o "$BIN_DIR/codex-x86_64-unknown-linux-musl"
+# ARM64 Linux
+zstd -d "$ARTIFACTS_DIR/aarch64-unknown-linux-musl/codex-aarch64-unknown-linux-musl.zst" \
+ -o "$BIN_DIR/codex-aarch64-unknown-linux-musl"
+# x64 macOS
+zstd -d "$ARTIFACTS_DIR/x86_64-apple-darwin/codex-x86_64-apple-darwin.zst" \
+ -o "$BIN_DIR/codex-x86_64-apple-darwin"
+# ARM64 macOS
+zstd -d "$ARTIFACTS_DIR/aarch64-apple-darwin/codex-aarch64-apple-darwin.zst" \
+ -o "$BIN_DIR/codex-aarch64-apple-darwin"
+# x64 Windows
+zstd -d "$ARTIFACTS_DIR/x86_64-pc-windows-msvc/codex-x86_64-pc-windows-msvc.exe.zst" \
+ -o "$BIN_DIR/codex-x86_64-pc-windows-msvc.exe"
+# ARM64 Windows
+zstd -d "$ARTIFACTS_DIR/aarch64-pc-windows-msvc/codex-aarch64-pc-windows-msvc.exe.zst" \
+ -o "$BIN_DIR/codex-aarch64-pc-windows-msvc.exe"
+
+echo "Installed native dependencies into $BIN_DIR"
diff --git a/codex-cli/scripts/stage_release.sh b/codex-cli/scripts/stage_release.sh
new file mode 100755
index 0000000000..96236fc53c
--- /dev/null
+++ b/codex-cli/scripts/stage_release.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+# -----------------------------------------------------------------------------
+# stage_release.sh
+# -----------------------------------------------------------------------------
+# Stages an npm release for @openai/codex.
+#
+# Usage:
+#
+# --tmp
: Use instead of a freshly created temp directory.
+# -h|--help : Print usage.
+#
+# -----------------------------------------------------------------------------
+
+set -euo pipefail
+
+# Helper - usage / flag parsing
+
+usage() {
+ cat <&2
+ usage 1
+ ;;
+ *)
+ echo "Unexpected extra argument: $1" >&2
+ usage 1
+ ;;
+ esac
+ shift
+done
+
+# Fallback when the caller did not specify a directory.
+# If no directory was specified create a fresh temporary one.
+if [[ -z "$TMPDIR" ]]; then
+ TMPDIR="$(mktemp -d)"
+fi
+
+# Ensure the directory exists, then resolve to an absolute path.
+mkdir -p "$TMPDIR"
+TMPDIR="$(cd "$TMPDIR" && pwd)"
+
+# Main build logic
+
+echo "Staging release in $TMPDIR"
+
+# The script lives in codex-cli/scripts/ - change into codex-cli root so that
+# relative paths keep working.
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+pushd "$CODEX_CLI_ROOT" >/dev/null
+
+# 1. Build the JS artifacts ---------------------------------------------------
+
+# Paths inside the staged package
+mkdir -p "$TMPDIR/bin"
+
+cp -r bin/codex.js "$TMPDIR/bin/codex.js"
+cp ../README.md "$TMPDIR" || true # README is one level up - ignore if missing
+
+# Modify package.json - bump version and optionally add the native directory to
+# the files array so that the binaries are published to npm.
+
+jq --arg version "$VERSION" \
+ '.version = $version' \
+ package.json > "$TMPDIR/package.json"
+
+# 2. Native runtime deps (sandbox plus optional Rust binaries)
+
+./scripts/install_native_deps.sh --workflow-url "$WORKFLOW_URL" "$TMPDIR"
+
+popd >/dev/null
+
+echo "Staged version $VERSION for release in $TMPDIR"
+
+echo "Verify the CLI:"
+echo " node ${TMPDIR}/bin/codex.js --version"
+echo " node ${TMPDIR}/bin/codex.js --help"
+
+# Print final hint for convenience
+echo "Next: cd \"$TMPDIR\" && npm publish"
diff --git a/codex-cli/scripts/stage_rust_release.py b/codex-cli/scripts/stage_rust_release.py
new file mode 100755
index 0000000000..9a554b77d0
--- /dev/null
+++ b/codex-cli/scripts/stage_rust_release.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+
+import json
+import subprocess
+import sys
+import argparse
+from pathlib import Path
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="""Stage a release for the npm module.
+
+Run this after the GitHub Release has been created and use
+`--release-version` to specify the version to release.
+
+Optionally pass `--tmp` to control the temporary staging directory that will be
+forwarded to stage_release.sh.
+"""
+ )
+ parser.add_argument(
+ "--release-version", required=True, help="Version to release, e.g., 0.3.0"
+ )
+ parser.add_argument(
+ "--tmp",
+ help="Optional path to stage the npm package; forwarded to stage_release.sh",
+ )
+ args = parser.parse_args()
+ version = args.release_version
+
+ gh_run = subprocess.run(
+ [
+ "gh",
+ "run",
+ "list",
+ "--branch",
+ f"rust-v{version}",
+ "--json",
+ "workflowName,url,headSha",
+ "--jq",
+ 'first(.[] | select(.workflowName == "rust-release"))',
+ ],
+ stdout=subprocess.PIPE,
+ check=True,
+ )
+ gh_run.check_returncode()
+ workflow = json.loads(gh_run.stdout)
+ sha = workflow["headSha"]
+
+ print(f"should `git checkout {sha}`")
+
+ current_dir = Path(__file__).parent.resolve()
+ cmd = [
+ str(current_dir / "stage_release.sh"),
+ "--version",
+ version,
+ "--workflow-url",
+ workflow["url"],
+ ]
+ if args.tmp:
+ cmd.extend(["--tmp", args.tmp])
+
+ stage_release = subprocess.run(cmd)
+ stage_release.check_returncode()
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 5b74ec3263..f9d3620c0b 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -78,6 +78,12 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -490,16 +496,17 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "chrono"
-version = "0.4.42"
+version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
+ "android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
- "windows-link 0.2.0",
+ "windows-link",
]
[[package]]
@@ -588,6 +595,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
+ "once_cell",
"pretty_assertions",
"similar",
"tempfile",
@@ -656,10 +664,13 @@ dependencies = [
name = "codex-common"
version = "0.0.0"
dependencies = [
+ "async-trait",
"clap",
"codex-core",
"codex-protocol",
"serde",
+ "thiserror 2.0.16",
+ "tokio",
"toml",
]
@@ -948,6 +959,7 @@ dependencies = [
"lazy_static",
"libc",
"mcp-types",
+ "once_cell",
"path-clean",
"pathdiff",
"pretty_assertions",
@@ -969,21 +981,11 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"unicode-segmentation",
- "unicode-width 0.2.1",
+ "unicode-width 0.1.14",
"url",
"vt100",
]
-[[package]]
-name = "codex-utils-readiness"
-version = "0.0.0"
-dependencies = [
- "async-trait",
- "thiserror 2.0.16",
- "time",
- "tokio",
-]
-
[[package]]
name = "color-eyre"
version = "0.6.5"
@@ -1279,12 +1281,12 @@ dependencies = [
[[package]]
name = "deranged"
-version = "0.5.4"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
+checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
- "serde_core",
+ "serde",
]
[[package]]
@@ -2684,9 +2686,9 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.28"
+version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "logos"
@@ -3927,9 +3929,9 @@ dependencies = [
[[package]]
name = "serde"
-version = "1.0.226"
+version = "1.0.224"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd"
+checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b"
dependencies = [
"serde_core",
"serde_derive",
@@ -3937,18 +3939,18 @@ dependencies = [
[[package]]
name = "serde_core"
-version = "1.0.226"
+version = "1.0.224"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4"
+checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
-version = "1.0.226"
+version = "1.0.224"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33"
+checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0"
dependencies = [
"proc-macro2",
"quote",
@@ -4451,15 +4453,15 @@ dependencies = [
[[package]]
name = "tempfile"
-version = "3.23.0"
+version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
+checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.8",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -4583,9 +4585,9 @@ dependencies = [
[[package]]
name = "time"
-version = "0.3.44"
+version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
+checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
@@ -4600,15 +4602,15 @@ dependencies = [
[[package]]
name = "time-core"
-version = "0.1.6"
+version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
+checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
-version = "0.2.24"
+version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
+checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
@@ -5336,7 +5338,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
- "windows-link 0.1.3",
+ "windows-link",
"windows-result",
"windows-strings",
]
@@ -5369,19 +5371,13 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
-[[package]]
-name = "windows-link"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
-
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
- "windows-link 0.1.3",
+ "windows-link",
"windows-result",
"windows-strings",
]
@@ -5392,7 +5388,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
- "windows-link 0.1.3",
+ "windows-link",
]
[[package]]
@@ -5401,7 +5397,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
- "windows-link 0.1.3",
+ "windows-link",
]
[[package]]
diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml
index 57e606cbb9..8303bda53b 100644
--- a/codex-rs/Cargo.toml
+++ b/codex-rs/Cargo.toml
@@ -19,7 +19,6 @@ members = [
"protocol",
"protocol-ts",
"tui",
- "utils/readiness",
]
resolver = "2"
@@ -50,7 +49,6 @@ codex-ollama = { path = "ollama" }
codex-protocol = { path = "protocol" }
codex-protocol-ts = { path = "protocol-ts" }
codex-tui = { path = "tui" }
-codex-utils-readiness = { path = "utils/readiness" }
core_test_support = { path = "core/tests/common" }
mcp-types = { path = "mcp-types" }
mcp_test_support = { path = "mcp-server/tests/common" }
@@ -67,7 +65,7 @@ async-stream = "0.3.6"
async-trait = "0.1.89"
base64 = "0.22.1"
bytes = "1.10.1"
-chrono = "0.4.42"
+chrono = "0.4.40"
clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
@@ -94,6 +92,7 @@ maplit = "1.0.2"
mime_guess = "2.0.5"
multimap = "0.10.0"
nucleo-matcher = "0.3.1"
+once_cell = "1"
openssl-sys = "*"
os_info = "3.12.0"
owo-colors = "4.2.0"
@@ -122,7 +121,7 @@ strum = "0.27.2"
strum_macros = "0.27.2"
supports-color = "3.0.2"
sys-locale = "0.3.2"
-tempfile = "3.23.0"
+tempfile = "3.13.0"
textwrap = "0.16.2"
thiserror = "2.0.16"
time = "0.3"
@@ -140,7 +139,7 @@ tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
ts-rs = "11"
unicode-segmentation = "1.12.0"
-unicode-width = "0.2"
+unicode-width = "0.1"
url = "2"
urlencoding = "2.1"
uuid = "1"
@@ -192,7 +191,7 @@ unwrap_used = "deny"
# cargo-shear cannot see the platform-specific openssl-sys usage, so we
# silence the false positive here instead of deleting a real dependency.
[workspace.metadata.cargo-shear]
-ignored = ["openssl-sys", "codex-utils-readiness"]
+ignored = ["openssl-sys"]
[profile.release]
lto = "fat"
diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml
index 9445ae08b7..d37404c15b 100644
--- a/codex-rs/apply-patch/Cargo.toml
+++ b/codex-rs/apply-patch/Cargo.toml
@@ -20,6 +20,7 @@ similar = { workspace = true }
thiserror = { workspace = true }
tree-sitter = { workspace = true }
tree-sitter-bash = { workspace = true }
+once_cell = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs
index 3737c6ea6d..189c1a07d0 100644
--- a/codex-rs/apply-patch/src/lib.rs
+++ b/codex-rs/apply-patch/src/lib.rs
@@ -6,10 +6,10 @@ use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::str::Utf8Error;
-use std::sync::LazyLock;
use anyhow::Context;
use anyhow::Result;
+use once_cell::sync::Lazy;
pub use parser::Hunk;
pub use parser::ParseError;
use parser::ParseError::*;
@@ -351,7 +351,7 @@ fn extract_apply_patch_from_bash(
// also run an arbitrary query against the AST. This is useful for understanding
// how tree-sitter parses the script and whether the query syntax is correct. Be sure
// to test both positive and negative cases.
- static APPLY_PATCH_QUERY: LazyLock = LazyLock::new(|| {
+ static APPLY_PATCH_QUERY: Lazy = Lazy::new(|| {
let language = BASH.into();
#[expect(clippy::expect_used)]
Query::new(
diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml
index 3ce84a6f50..fe44be75aa 100644
--- a/codex-rs/common/Cargo.toml
+++ b/codex-rs/common/Cargo.toml
@@ -7,11 +7,14 @@ version = { workspace = true }
workspace = true
[dependencies]
+async-trait = { workspace = true }
clap = { workspace = true, features = ["derive", "wrap_help"], optional = true }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
serde = { workspace = true, optional = true }
toml = { workspace = true, optional = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
[features]
# Separate feature so that `clap` is not a mandatory dependency.
diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs
index 292503f77e..2c5d91e1eb 100644
--- a/codex-rs/common/src/lib.rs
+++ b/codex-rs/common/src/lib.rs
@@ -34,3 +34,5 @@ pub mod model_presets;
// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server
// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy.
pub mod approval_presets;
+// Readiness flag with token-based authorization and async waiting (Tokio).
+pub mod readiness;
diff --git a/codex-rs/utils/readiness/src/lib.rs b/codex-rs/common/src/readiness.rs
similarity index 100%
rename from codex-rs/utils/readiness/src/lib.rs
rename to codex-rs/common/src/readiness.rs
diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs
index 1ebbe5d738..0afc06e99c 100644
--- a/codex-rs/core/src/apply_patch.rs
+++ b/codex-rs/core/src/apply_patch.rs
@@ -1,12 +1,13 @@
use crate::codex::Session;
use crate::codex::TurnContext;
-use crate::function_tool::FunctionCallError;
use crate::protocol::FileChange;
use crate::protocol::ReviewDecision;
use crate::safety::SafetyCheck;
use crate::safety::assess_patch_safety;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
+use codex_protocol::models::FunctionCallOutputPayload;
+use codex_protocol::models::ResponseInputItem;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -16,7 +17,7 @@ pub(crate) enum InternalApplyPatchInvocation {
/// The `apply_patch` call was handled programmatically, without any sort
/// of sandbox, because the user explicitly approved it. This is the
/// result to use with the `shell` function call that contained `apply_patch`.
- Output(Result),
+ Output(ResponseInputItem),
/// The `apply_patch` call was approved, either automatically because it
/// appears that it should be allowed based on the user's sandbox policy
@@ -32,6 +33,12 @@ pub(crate) struct ApplyPatchExec {
pub(crate) user_explicitly_approved_this_action: bool,
}
+impl From for InternalApplyPatchInvocation {
+ fn from(item: ResponseInputItem) -> Self {
+ InternalApplyPatchInvocation::Output(item)
+ }
+}
+
pub(crate) async fn apply_patch(
sess: &Session,
turn_context: &TurnContext,
@@ -70,15 +77,25 @@ pub(crate) async fn apply_patch(
})
}
ReviewDecision::Denied | ReviewDecision::Abort => {
- InternalApplyPatchInvocation::Output(Err(FunctionCallError::RespondToModel(
- "patch rejected by user".to_string(),
- )))
+ ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.to_owned(),
+ output: FunctionCallOutputPayload {
+ content: "patch rejected by user".to_string(),
+ success: Some(false),
+ },
+ }
+ .into()
}
}
}
- SafetyCheck::Reject { reason } => InternalApplyPatchInvocation::Output(Err(
- FunctionCallError::RespondToModel(format!("patch rejected: {reason}")),
- )),
+ SafetyCheck::Reject { reason } => ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.to_owned(),
+ output: FunctionCallOutputPayload {
+ content: format!("patch rejected: {reason}"),
+ success: Some(false),
+ },
+ }
+ .into(),
}
}
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index e088f9c3ab..0ae057b3e8 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -415,6 +415,9 @@ struct SseEvent {
delta: Option,
}
+#[derive(Debug, Deserialize)]
+struct ResponseCreated {}
+
#[derive(Debug, Deserialize)]
struct ResponseCompleted {
id: String,
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 84a04d7533..a2b0b94934 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -10,7 +10,6 @@ use std::time::Duration;
use crate::AuthManager;
use crate::client_common::REVIEW_PROMPT;
use crate::event_mapping::map_response_item_to_event_messages;
-use crate::function_tool::FunctionCallError;
use crate::review_format::format_review_findings_block;
use crate::user_notification::UserNotifier;
use async_channel::Receiver;
@@ -2233,41 +2232,18 @@ async fn handle_response_item(
..
} => {
info!("FunctionCall: {name}({arguments})");
- if let Some((server, tool_name)) = sess.mcp_connection_manager.parse_tool_name(&name) {
- let resp = handle_mcp_tool_call(
- sess,
- sub_id,
- call_id.clone(),
- server,
- tool_name,
- arguments,
- )
- .await;
- Some(resp)
- } else {
- let result = handle_function_call(
+ Some(
+ handle_function_call(
sess,
turn_context,
turn_diff_tracker,
sub_id.to_string(),
name,
arguments,
- call_id.clone(),
+ call_id,
)
- .await;
-
- let output = match result {
- Ok(content) => FunctionCallOutputPayload {
- content,
- success: Some(true),
- },
- Err(FunctionCallError::RespondToModel(msg)) => FunctionCallOutputPayload {
- content: msg,
- success: Some(false),
- },
- };
- Some(ResponseInputItem::FunctionCallOutput { call_id, output })
- }
+ .await,
+ )
}
ResponseItem::LocalShellCall {
id,
@@ -2300,32 +2276,17 @@ async fn handle_response_item(
};
let exec_params = to_exec_params(params, turn_context);
- {
- let result = handle_container_exec_with_params(
+ Some(
+ handle_container_exec_with_params(
exec_params,
sess,
turn_context,
turn_diff_tracker,
sub_id.to_string(),
- effective_call_id.clone(),
+ effective_call_id,
)
- .await;
-
- let output = match result {
- Ok(content) => FunctionCallOutputPayload {
- content,
- success: Some(true),
- },
- Err(FunctionCallError::RespondToModel(msg)) => FunctionCallOutputPayload {
- content: msg,
- success: Some(false),
- },
- };
- Some(ResponseInputItem::FunctionCallOutput {
- call_id: effective_call_id,
- output,
- })
- }
+ .await,
+ )
}
ResponseItem::CustomToolCall {
id: _,
@@ -2333,24 +2294,18 @@ async fn handle_response_item(
name,
input,
status: _,
- } => {
- let result = handle_custom_tool_call(
+ } => Some(
+ handle_custom_tool_call(
sess,
turn_context,
turn_diff_tracker,
sub_id.to_string(),
name,
input,
- call_id.clone(),
+ call_id,
)
- .await;
-
- let output = match result {
- Ok(content) => content,
- Err(FunctionCallError::RespondToModel(msg)) => msg,
- };
- Some(ResponseInputItem::CustomToolCallOutput { call_id, output })
- }
+ .await,
+ ),
ResponseItem::FunctionCallOutput { .. } => {
debug!("unexpected FunctionCallOutput from stream");
None
@@ -2387,17 +2342,22 @@ async fn handle_response_item(
async fn handle_unified_exec_tool_call(
sess: &Session,
+ call_id: String,
session_id: Option,
arguments: Vec,
timeout_ms: Option,
-) -> Result {
+) -> ResponseInputItem {
let parsed_session_id = if let Some(session_id) = session_id {
match session_id.parse::() {
Ok(parsed) => Some(parsed),
Err(output) => {
- return Err(FunctionCallError::RespondToModel(format!(
- "invalid session_id: {session_id} due to error {output:?}"
- )));
+ return ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.to_string(),
+ output: FunctionCallOutputPayload {
+ content: format!("invalid session_id: {session_id} due to error {output}"),
+ success: Some(false),
+ },
+ };
}
}
} else {
@@ -2410,29 +2370,40 @@ async fn handle_unified_exec_tool_call(
timeout_ms,
};
- let value = sess
- .unified_exec_manager
- .handle_request(request)
- .await
- .map_err(|err| {
- FunctionCallError::RespondToModel(format!("unified exec failed: {err:?}"))
- })?;
+ let result = sess.unified_exec_manager.handle_request(request).await;
- #[derive(Serialize)]
- struct SerializedUnifiedExecResult {
- session_id: Option,
- output: String,
+ let output_payload = match result {
+ Ok(value) => {
+ #[derive(Serialize)]
+ struct SerializedUnifiedExecResult<'a> {
+ session_id: Option,
+ output: &'a str,
+ }
+
+ match serde_json::to_string(&SerializedUnifiedExecResult {
+ session_id: value.session_id.map(|id| id.to_string()),
+ output: &value.output,
+ }) {
+ Ok(serialized) => FunctionCallOutputPayload {
+ content: serialized,
+ success: Some(true),
+ },
+ Err(err) => FunctionCallOutputPayload {
+ content: format!("failed to serialize unified exec output: {err}"),
+ success: Some(false),
+ },
+ }
+ }
+ Err(err) => FunctionCallOutputPayload {
+ content: format!("unified exec failed: {err}"),
+ success: Some(false),
+ },
+ };
+
+ ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: output_payload,
}
-
- serde_json::to_string(&SerializedUnifiedExecResult {
- session_id: value.session_id.map(|id| id.to_string()),
- output: value.output,
- })
- .map_err(|err| {
- FunctionCallError::RespondToModel(format!(
- "failed to serialize unified exec output: {err:?}"
- ))
- })
}
async fn handle_function_call(
@@ -2443,10 +2414,15 @@ async fn handle_function_call(
name: String,
arguments: String,
call_id: String,
-) -> Result {
+) -> ResponseInputItem {
match name.as_str() {
"container.exec" | "shell" => {
- let params = parse_container_exec_arguments(arguments, turn_context, &call_id)?;
+ let params = match parse_container_exec_arguments(arguments, turn_context, &call_id) {
+ Ok(params) => params,
+ Err(output) => {
+ return *output;
+ }
+ };
handle_container_exec_with_params(
params,
sess,
@@ -2467,41 +2443,74 @@ async fn handle_function_call(
timeout_ms: Option,
}
- let args: UnifiedExecArgs = serde_json::from_str(&arguments).map_err(|err| {
- FunctionCallError::RespondToModel(format!(
- "failed to parse function arguments: {err:?}"
- ))
- })?;
+ let args = match serde_json::from_str::(&arguments) {
+ Ok(args) => args,
+ Err(err) => {
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!("failed to parse function arguments: {err}"),
+ success: Some(false),
+ },
+ };
+ }
+ };
- handle_unified_exec_tool_call(sess, args.session_id, args.input, args.timeout_ms).await
+ handle_unified_exec_tool_call(
+ sess,
+ call_id,
+ args.session_id,
+ args.input,
+ args.timeout_ms,
+ )
+ .await
}
"view_image" => {
#[derive(serde::Deserialize)]
struct SeeImageArgs {
path: String,
}
- let args: SeeImageArgs = serde_json::from_str(&arguments).map_err(|e| {
- FunctionCallError::RespondToModel(format!(
- "failed to parse function arguments: {e:?}"
- ))
- })?;
+ let args = match serde_json::from_str::(&arguments) {
+ Ok(a) => a,
+ Err(e) => {
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!("failed to parse function arguments: {e}"),
+ success: Some(false),
+ },
+ };
+ }
+ };
let abs = turn_context.resolve_path(Some(args.path));
- sess.inject_input(vec![InputItem::LocalImage { path: abs }])
+ let output = match sess
+ .inject_input(vec![InputItem::LocalImage { path: abs }])
.await
- .map_err(|_| {
- FunctionCallError::RespondToModel(
- "unable to attach image (no active task)".to_string(),
- )
- })?;
-
- Ok("attached local image path".to_string())
+ {
+ Ok(()) => FunctionCallOutputPayload {
+ content: "attached local image path".to_string(),
+ success: Some(true),
+ },
+ Err(_) => FunctionCallOutputPayload {
+ content: "unable to attach image (no active task)".to_string(),
+ success: Some(false),
+ },
+ };
+ ResponseInputItem::FunctionCallOutput { call_id, output }
}
"apply_patch" => {
- let args: ApplyPatchToolArgs = serde_json::from_str(&arguments).map_err(|e| {
- FunctionCallError::RespondToModel(format!(
- "failed to parse function arguments: {e:?}"
- ))
- })?;
+ let args = match serde_json::from_str::(&arguments) {
+ Ok(a) => a,
+ Err(e) => {
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!("failed to parse function arguments: {e}"),
+ success: None,
+ },
+ };
+ }
+ };
let exec_params = ExecParams {
command: vec!["apply_patch".to_string(), args.input.clone()],
cwd: turn_context.cwd.clone(),
@@ -2523,39 +2532,69 @@ async fn handle_function_call(
"update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
EXEC_COMMAND_TOOL_NAME => {
// TODO(mbolin): Sandbox check.
- let exec_params: ExecCommandParams = serde_json::from_str(&arguments).map_err(|e| {
- FunctionCallError::RespondToModel(format!(
- "failed to parse function arguments: {e:?}"
- ))
- })?;
+ let exec_params = match serde_json::from_str::(&arguments) {
+ Ok(params) => params,
+ Err(e) => {
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!("failed to parse function arguments: {e}"),
+ success: Some(false),
+ },
+ };
+ }
+ };
let result = sess
.session_manager
.handle_exec_command_request(exec_params)
.await;
- match result {
- Ok(output) => Ok(output.to_text_output()),
- Err(err) => Err(FunctionCallError::RespondToModel(err)),
+ let function_call_output = crate::exec_command::result_into_payload(result);
+ ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: function_call_output,
}
}
WRITE_STDIN_TOOL_NAME => {
- let write_stdin_params =
- serde_json::from_str::(&arguments).map_err(|e| {
- FunctionCallError::RespondToModel(format!(
- "failed to parse function arguments: {e:?}"
- ))
- })?;
-
+ let write_stdin_params = match serde_json::from_str::(&arguments) {
+ Ok(params) => params,
+ Err(e) => {
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!("failed to parse function arguments: {e}"),
+ success: Some(false),
+ },
+ };
+ }
+ };
let result = sess
.session_manager
.handle_write_stdin_request(write_stdin_params)
- .await
- .map_err(FunctionCallError::RespondToModel)?;
-
- Ok(result.to_text_output())
+ .await;
+ let function_call_output: FunctionCallOutputPayload =
+ crate::exec_command::result_into_payload(result);
+ ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: function_call_output,
+ }
+ }
+ _ => {
+ match sess.mcp_connection_manager.parse_tool_name(&name) {
+ Some((server, tool_name)) => {
+ handle_mcp_tool_call(sess, &sub_id, call_id, server, tool_name, arguments).await
+ }
+ None => {
+ // Unknown function: reply with structured failure so the model can adapt.
+ ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!("unsupported call: {name}"),
+ success: None,
+ },
+ }
+ }
+ }
}
- _ => Err(FunctionCallError::RespondToModel(format!(
- "unsupported call: {name}"
- ))),
}
}
@@ -2567,7 +2606,7 @@ async fn handle_custom_tool_call(
name: String,
input: String,
call_id: String,
-) -> Result {
+) -> ResponseInputItem {
info!("CustomToolCall: {name} {input}");
match name.as_str() {
"apply_patch" => {
@@ -2579,8 +2618,7 @@ async fn handle_custom_tool_call(
with_escalated_permissions: None,
justification: None,
};
-
- handle_container_exec_with_params(
+ let resp = handle_container_exec_with_params(
exec_params,
sess,
turn_context,
@@ -2588,13 +2626,26 @@ async fn handle_custom_tool_call(
sub_id,
call_id,
)
- .await
+ .await;
+
+ // Convert function-call style output into a custom tool call output
+ match resp {
+ ResponseInputItem::FunctionCallOutput { call_id, output } => {
+ ResponseInputItem::CustomToolCallOutput {
+ call_id,
+ output: output.content,
+ }
+ }
+ // Pass through if already a custom tool output or other variant
+ other => other,
+ }
}
_ => {
debug!("unexpected CustomToolCall from stream");
- Err(FunctionCallError::RespondToModel(format!(
- "unsupported custom tool call: {name}"
- )))
+ ResponseInputItem::CustomToolCallOutput {
+ call_id,
+ output: format!("unsupported custom tool call: {name}"),
+ }
}
}
}
@@ -2613,13 +2664,23 @@ fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> Ex
fn parse_container_exec_arguments(
arguments: String,
turn_context: &TurnContext,
- _call_id: &str,
-) -> Result {
- serde_json::from_str::(&arguments)
- .map(|p| to_exec_params(p, turn_context))
- .map_err(|e| {
- FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e:?}"))
- })
+ call_id: &str,
+) -> Result> {
+ // parse command
+ match serde_json::from_str::(&arguments) {
+ Ok(shell_tool_call_params) => Ok(to_exec_params(shell_tool_call_params, turn_context)),
+ Err(e) => {
+ // allow model to re-sample
+ let output = ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.to_string(),
+ output: FunctionCallOutputPayload {
+ content: format!("failed to parse function arguments: {e}"),
+ success: None,
+ },
+ };
+ Err(Box::new(output))
+ }
+ }
}
pub struct ExecInvokeArgs<'a> {
@@ -2656,14 +2717,20 @@ async fn handle_container_exec_with_params(
turn_diff_tracker: &mut TurnDiffTracker,
sub_id: String,
call_id: String,
-) -> Result {
+) -> ResponseInputItem {
if params.with_escalated_permissions.unwrap_or(false)
&& !matches!(turn_context.approval_policy, AskForApproval::OnRequest)
{
- return Err(FunctionCallError::RespondToModel(format!(
- "approval policy is {policy:?}; reject command โ you should not ask for escalated permissions if the approval policy is {policy:?}",
- policy = turn_context.approval_policy
- )));
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!(
+ "approval policy is {policy:?}; reject command โ you should not ask for escalated permissions if the approval policy is {policy:?}",
+ policy = turn_context.approval_policy
+ ),
+ success: None,
+ },
+ };
}
// check if this was a patch, and apply it if so
@@ -2680,9 +2747,13 @@ async fn handle_container_exec_with_params(
// It looks like an invocation of `apply_patch`, but we
// could not resolve it into a patch that would apply
// cleanly. Return to model for resample.
- return Err(FunctionCallError::RespondToModel(format!(
- "error: {parse_error:#?}"
- )));
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!("error: {parse_error:#}"),
+ success: None,
+ },
+ };
}
MaybeApplyPatchVerified::ShellParseError(error) => {
trace!("Failed to parse shell command, {error:?}");
@@ -2700,9 +2771,13 @@ async fn handle_container_exec_with_params(
.ok()
.map(|p| p.to_string_lossy().to_string());
let Some(path_to_codex) = path_to_codex else {
- return Err(FunctionCallError::RespondToModel(
- "failed to determine path to codex executable".to_string(),
- ));
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: "failed to determine path to codex executable".to_string(),
+ success: None,
+ },
+ };
};
let params = ExecParams {
@@ -2768,9 +2843,13 @@ async fn handle_container_exec_with_params(
sess.add_approved_command(params.command.clone()).await;
}
ReviewDecision::Denied | ReviewDecision::Abort => {
- return Err(FunctionCallError::RespondToModel(
- "exec command rejected by user".to_string(),
- ));
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: "exec command rejected by user".to_string(),
+ success: None,
+ },
+ };
}
}
// No sandboxing is applied because the user has given
@@ -2780,9 +2859,13 @@ async fn handle_container_exec_with_params(
SandboxType::None
}
SafetyCheck::Reject { reason } => {
- return Err(FunctionCallError::RespondToModel(format!(
- "exec command rejected: {reason:?}"
- )));
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!("exec command rejected: {reason}"),
+ success: None,
+ },
+ };
}
};
@@ -2829,11 +2912,15 @@ async fn handle_container_exec_with_params(
match output_result {
Ok(output) => {
let ExecToolCallOutput { exit_code, .. } = &output;
+
+ let is_success = *exit_code == 0;
let content = format_exec_output(&output);
- if *exit_code == 0 {
- Ok(content)
- } else {
- Err(FunctionCallError::RespondToModel(content))
+ ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.clone(),
+ output: FunctionCallOutputPayload {
+ content,
+ success: Some(is_success),
+ },
}
}
Err(CodexErr::Sandbox(error)) => {
@@ -2848,9 +2935,13 @@ async fn handle_container_exec_with_params(
)
.await
}
- Err(e) => Err(FunctionCallError::RespondToModel(format!(
- "execution error: {e:?}"
- ))),
+ Err(e) => ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.clone(),
+ output: FunctionCallOutputPayload {
+ content: format!("execution error: {e}"),
+ success: None,
+ },
+ },
}
}
@@ -2862,23 +2953,35 @@ async fn handle_sandbox_error(
sandbox_type: SandboxType,
sess: &Session,
turn_context: &TurnContext,
-) -> Result {
+) -> ResponseInputItem {
let call_id = exec_command_context.call_id.clone();
let sub_id = exec_command_context.sub_id.clone();
let cwd = exec_command_context.cwd.clone();
if let SandboxErr::Timeout { output } = &error {
let content = format_exec_output(output);
- return Err(FunctionCallError::RespondToModel(content));
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content,
+ success: Some(false),
+ },
+ };
}
// Early out if either the user never wants to be asked for approval, or
// we're letting the model manage escalation requests. Otherwise, continue
match turn_context.approval_policy {
AskForApproval::Never | AskForApproval::OnRequest => {
- return Err(FunctionCallError::RespondToModel(format!(
- "failed in sandbox {sandbox_type:?} with execution error: {error:?}"
- )));
+ return ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: format!(
+ "failed in sandbox {sandbox_type:?} with execution error: {error}"
+ ),
+ success: Some(false),
+ },
+ };
}
AskForApproval::UnlessTrusted | AskForApproval::OnFailure => (),
}
@@ -2944,23 +3047,36 @@ async fn handle_sandbox_error(
match retry_output_result {
Ok(retry_output) => {
let ExecToolCallOutput { exit_code, .. } = &retry_output;
+
+ let is_success = *exit_code == 0;
let content = format_exec_output(&retry_output);
- if *exit_code == 0 {
- Ok(content)
- } else {
- Err(FunctionCallError::RespondToModel(content))
+
+ ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.clone(),
+ output: FunctionCallOutputPayload {
+ content,
+ success: Some(is_success),
+ },
}
}
- Err(e) => Err(FunctionCallError::RespondToModel(format!(
- "retry failed: {e}"
- ))),
+ Err(e) => ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.clone(),
+ output: FunctionCallOutputPayload {
+ content: format!("retry failed: {e}"),
+ success: None,
+ },
+ },
}
}
ReviewDecision::Denied | ReviewDecision::Abort => {
// Fall through to original failure handling.
- Err(FunctionCallError::RespondToModel(
- "exec command rejected by user".to_string(),
- ))
+ ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: "exec command rejected by user".to_string(),
+ success: None,
+ },
+ }
}
}
}
@@ -3682,8 +3798,8 @@ mod tests {
)
.await;
- let Err(FunctionCallError::RespondToModel(output)) = resp else {
- panic!("expected error result");
+ let ResponseInputItem::FunctionCallOutput { output, .. } = resp else {
+ panic!("expected FunctionCallOutput");
};
let expected = format!(
@@ -3691,7 +3807,7 @@ mod tests {
policy = turn_context.approval_policy
);
- pretty_assertions::assert_eq!(output, expected);
+ pretty_assertions::assert_eq!(output.content, expected);
// Now retry the same command WITHOUT escalated permissions; should succeed.
// Force DangerFullAccess to avoid platform sandbox dependencies in tests.
@@ -3707,7 +3823,9 @@ mod tests {
)
.await;
- let output = resp2.expect("expected Ok result");
+ let ResponseInputItem::FunctionCallOutput { output, .. } = resp2 else {
+ panic!("expected FunctionCallOutput on retry");
+ };
#[derive(Deserialize, PartialEq, Eq, Debug)]
struct ResponseExecMetadata {
@@ -3721,9 +3839,10 @@ mod tests {
}
let exec_output: ResponseExecOutput =
- serde_json::from_str(&output).expect("valid exec output json");
+ serde_json::from_str(&output.content).expect("valid exec output json");
pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 });
assert!(exec_output.output.contains("hi"));
+ pretty_assertions::assert_eq!(output.success, Some(true));
}
}
diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs
index 8f213d4e7e..016f406f33 100644
--- a/codex-rs/core/src/codex/compact.rs
+++ b/codex-rs/core/src/codex/compact.rs
@@ -55,7 +55,7 @@ pub(super) async fn run_inline_auto_compact_task(
let input = vec![InputItem::Text {
text: SUMMARIZATION_PROMPT.to_string(),
}];
- run_compact_task_inner(sess, turn_context, sub_id, input, false).await;
+ run_compact_task_inner(sess, turn_context, sub_id, input, None, false).await;
}
pub(super) async fn run_compact_task(
@@ -71,7 +71,15 @@ pub(super) async fn run_compact_task(
}),
};
sess.send_event(start_event).await;
- run_compact_task_inner(sess.clone(), turn_context, sub_id.clone(), input, true).await;
+ run_compact_task_inner(
+ sess.clone(),
+ turn_context,
+ sub_id.clone(),
+ input,
+ None,
+ true,
+ )
+ .await;
let event = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent {
@@ -86,6 +94,7 @@ async fn run_compact_task_inner(
turn_context: Arc,
sub_id: String,
input: Vec,
+ instructions_override: Option,
remove_task_on_completion: bool,
) {
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
@@ -95,7 +104,9 @@ async fn run_compact_task_inner(
let prompt = Prompt {
input: turn_input,
- ..Default::default()
+ tools: Vec::new(),
+ base_instructions_override: instructions_override,
+ output_schema: None,
};
let max_retries = turn_context.client.get_provider().stream_max_retries();
diff --git a/codex-rs/core/src/exec_command/mod.rs b/codex-rs/core/src/exec_command/mod.rs
index 2cfd022510..103b76ad67 100644
--- a/codex-rs/core/src/exec_command/mod.rs
+++ b/codex-rs/core/src/exec_command/mod.rs
@@ -12,3 +12,4 @@ pub use responses_api::WRITE_STDIN_TOOL_NAME;
pub use responses_api::create_exec_command_tool_for_responses_api;
pub use responses_api::create_write_stdin_tool_for_responses_api;
pub use session_manager::SessionManager as ExecSessionManager;
+pub use session_manager::result_into_payload;
diff --git a/codex-rs/core/src/exec_command/session_manager.rs b/codex-rs/core/src/exec_command/session_manager.rs
index cd1c5329a2..61c70f0cc2 100644
--- a/codex-rs/core/src/exec_command/session_manager.rs
+++ b/codex-rs/core/src/exec_command/session_manager.rs
@@ -21,6 +21,7 @@ use crate::exec_command::exec_command_params::WriteStdinParams;
use crate::exec_command::exec_command_session::ExecCommandSession;
use crate::exec_command::session_id::SessionId;
use crate::truncate::truncate_middle;
+use codex_protocol::models::FunctionCallOutputPayload;
#[derive(Debug, Default)]
pub struct SessionManager {
@@ -37,7 +38,7 @@ pub struct ExecCommandOutput {
}
impl ExecCommandOutput {
- pub(crate) fn to_text_output(&self) -> String {
+ fn to_text_output(&self) -> String {
let wall_time_secs = self.wall_time.as_secs_f32();
let termination_status = match self.exit_status {
ExitStatus::Exited(code) => format!("Process exited with code {code}"),
@@ -67,6 +68,19 @@ pub enum ExitStatus {
Ongoing(SessionId),
}
+pub fn result_into_payload(result: Result) -> FunctionCallOutputPayload {
+ match result {
+ Ok(output) => FunctionCallOutputPayload {
+ content: output.to_text_output(),
+ success: Some(true),
+ },
+ Err(err) => FunctionCallOutputPayload {
+ content: err,
+ success: Some(false),
+ },
+ }
+}
+
impl SessionManager {
/// Processes the request and is required to send a response via `outgoing`.
pub async fn handle_exec_command_request(
diff --git a/codex-rs/core/src/function_tool.rs b/codex-rs/core/src/function_tool.rs
deleted file mode 100644
index 756cef3e3e..0000000000
--- a/codex-rs/core/src/function_tool.rs
+++ /dev/null
@@ -1,7 +0,0 @@
-use thiserror::Error;
-
-#[derive(Debug, Error, PartialEq)]
-pub enum FunctionCallError {
- #[error("{0}")]
- RespondToModel(String),
-}
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index 8c5bcf94fe..f73bef4051 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -75,7 +75,6 @@ pub use rollout::find_conversation_path_by_id_str;
pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
-mod function_tool;
mod user_notification;
pub mod util;
diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs
index d1d2305aaa..3df00fbbc0 100644
--- a/codex-rs/core/src/openai_model_info.rs
+++ b/codex-rs/core/src/openai_model_info.rs
@@ -7,14 +7,13 @@ use crate::model_family::ModelFamily;
/// Though this would help present more accurate pricing information in the UI.
#[derive(Debug)]
pub(crate) struct ModelInfo {
- /// Size of the context window in tokens. This is the maximum size of the input context.
+ /// Size of the context window in tokens.
pub(crate) context_window: u64,
/// Maximum number of output tokens that can be generated for the model.
pub(crate) max_output_tokens: u64,
- /// Token threshold where we should automatically compact conversation history. This considers
- /// input tokens + output tokens of this turn.
+ /// Token threshold where we should automatically compact conversation history.
pub(crate) auto_compact_token_limit: Option,
}
@@ -65,7 +64,7 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option {
_ if slug.starts_with("gpt-5-codex") => Some(ModelInfo {
context_window: 272_000,
max_output_tokens: 128_000,
- auto_compact_token_limit: Some(350_000),
+ auto_compact_token_limit: Some(220_000),
}),
_ if slug.starts_with("gpt-5") => Some(ModelInfo::new(272_000, 128_000)),
diff --git a/codex-rs/core/src/plan_tool.rs b/codex-rs/core/src/plan_tool.rs
index c81675698f..a712b8b8dd 100644
--- a/codex-rs/core/src/plan_tool.rs
+++ b/codex-rs/core/src/plan_tool.rs
@@ -2,12 +2,13 @@ use std::collections::BTreeMap;
use std::sync::LazyLock;
use crate::codex::Session;
-use crate::function_tool::FunctionCallError;
use crate::openai_tools::JsonSchema;
use crate::openai_tools::OpenAiTool;
use crate::openai_tools::ResponsesApiTool;
use crate::protocol::Event;
use crate::protocol::EventMsg;
+use codex_protocol::models::FunctionCallOutputPayload;
+use codex_protocol::models::ResponseInputItem;
// Use the canonical plan tool types from the protocol crate to ensure
// type-identity matches events transported via `codex_protocol`.
@@ -66,20 +67,44 @@ pub(crate) async fn handle_update_plan(
session: &Session,
arguments: String,
sub_id: String,
- _call_id: String,
-) -> Result {
- let args = parse_update_plan_arguments(&arguments)?;
- session
- .send_event(Event {
- id: sub_id.to_string(),
- msg: EventMsg::PlanUpdate(args),
- })
- .await;
- Ok("Plan updated".to_string())
+ call_id: String,
+) -> ResponseInputItem {
+ match parse_update_plan_arguments(arguments, &call_id) {
+ Ok(args) => {
+ let output = ResponseInputItem::FunctionCallOutput {
+ call_id,
+ output: FunctionCallOutputPayload {
+ content: "Plan updated".to_string(),
+ success: Some(true),
+ },
+ };
+ session
+ .send_event(Event {
+ id: sub_id.to_string(),
+ msg: EventMsg::PlanUpdate(args),
+ })
+ .await;
+ output
+ }
+ Err(output) => *output,
+ }
}
-fn parse_update_plan_arguments(arguments: &str) -> Result {
- serde_json::from_str::(arguments).map_err(|e| {
- FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e}"))
- })
+fn parse_update_plan_arguments(
+ arguments: String,
+ call_id: &str,
+) -> Result> {
+ match serde_json::from_str::(&arguments) {
+ Ok(args) => Ok(args),
+ Err(e) => {
+ let output = ResponseInputItem::FunctionCallOutput {
+ call_id: call_id.to_string(),
+ output: FunctionCallOutputPayload {
+ content: format!("failed to parse function arguments: {e}"),
+ success: None,
+ },
+ };
+ Err(Box::new(output))
+ }
+ }
}
diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs
index 23e645413f..6befdb1fe9 100644
--- a/codex-rs/core/src/rollout/recorder.rs
+++ b/codex-rs/core/src/rollout/recorder.rs
@@ -7,6 +7,8 @@ use std::path::Path;
use std::path::PathBuf;
use codex_protocol::mcp_protocol::ConversationId;
+use serde::Deserialize;
+use serde::Serialize;
use serde_json::Value;
use time::OffsetDateTime;
use time::format_description::FormatItem;
@@ -26,6 +28,7 @@ use super::policy::is_persisted_response_item;
use crate::config::Config;
use crate::default_client::ORIGINATOR;
use crate::git_info::collect_git_info;
+use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
@@ -33,6 +36,19 @@ use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
+#[derive(Serialize, Deserialize, Default, Clone)]
+pub struct SessionStateSnapshot {}
+
+#[derive(Serialize, Deserialize, Default, Clone)]
+pub struct SavedSession {
+ pub session: SessionMeta,
+ #[serde(default)]
+ pub items: Vec,
+ #[serde(default)]
+ pub state: SessionStateSnapshot,
+ pub session_id: ConversationId,
+}
+
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
/// every update.
///
diff --git a/codex-rs/core/src/tool_apply_patch.rs b/codex-rs/core/src/tool_apply_patch.rs
index 164a1e127f..d4039fd192 100644
--- a/codex-rs/core/src/tool_apply_patch.rs
+++ b/codex-rs/core/src/tool_apply_patch.rs
@@ -10,6 +10,11 @@ use crate::openai_tools::ResponsesApiTool;
const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("tool_apply_patch.lark");
+#[derive(Serialize, Deserialize)]
+pub(crate) struct ApplyPatchToolArgs {
+ pub(crate) input: String,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ApplyPatchToolType {
diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs
index 2f5fe4fb1c..5774fc18ee 100644
--- a/codex-rs/exec/src/cli.rs
+++ b/codex-rs/exec/src/cli.rs
@@ -67,10 +67,6 @@ pub struct Cli {
#[arg(long = "json", default_value_t = false)]
pub json: bool,
- /// Whether to include the plan tool in the conversation.
- #[arg(long = "include-plan-tool", default_value_t = false)]
- pub include_plan_tool: bool,
-
/// Specifies file where the last message from the agent should be written.
#[arg(long = "output-last-message")]
pub last_message_file: Option,
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index cb972bf93e..b22d4b7289 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -539,37 +539,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
EventMsg::PlanUpdate(plan_update_event) => {
let UpdatePlanArgs { explanation, plan } = plan_update_event;
-
- // Header
- ts_println!(self, "{}", "Plan update".style(self.magenta));
-
- // Optional explanation
- if let Some(explanation) = explanation
- && !explanation.trim().is_empty()
- {
- ts_println!(self, "{}", explanation.style(self.italic));
- }
-
- // Pretty-print the plan items with simple status markers.
- for item in plan {
- use codex_core::plan_tool::StepStatus;
- match item.status {
- StepStatus::Completed => {
- ts_println!(self, " {} {}", "โ".style(self.green), item.step);
- }
- StepStatus::InProgress => {
- ts_println!(self, " {} {}", "โ".style(self.cyan), item.step);
- }
- StepStatus::Pending => {
- ts_println!(
- self,
- " {} {}",
- "โข".style(self.dimmed),
- item.step.style(self.dimmed)
- );
- }
- }
- }
+ ts_println!(self, "explanation: {explanation:?}");
+ ts_println!(self, "plan: {plan:?}");
}
EventMsg::GetHistoryEntryResponse(_) => {
// Currently ignored in exec output.
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
index 9c561cfb1f..ed8b26c1bd 100644
--- a/codex-rs/exec/src/lib.rs
+++ b/codex-rs/exec/src/lib.rs
@@ -53,7 +53,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any
sandbox_mode: sandbox_mode_cli_arg,
prompt,
output_schema: output_schema_path,
- include_plan_tool,
config_overrides,
} = cli;
@@ -162,7 +161,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any
model_provider,
codex_linux_sandbox_exe,
base_instructions: None,
- include_plan_tool: Some(include_plan_tool),
+ include_plan_tool: None,
include_apply_patch_tool: None,
include_view_image_tool: None,
show_raw_agent_reasoning: oss.then_some(true),
diff --git a/codex-rs/rust-toolchain.toml b/codex-rs/rust-toolchain.toml
index 7187a33189..0a9f6e8169 100644
--- a/codex-rs/rust-toolchain.toml
+++ b/codex-rs/rust-toolchain.toml
@@ -1,3 +1,3 @@
[toolchain]
-channel = "1.90.0"
-components = ["clippy", "rustfmt", "rust-src"]
+channel = "1.89.0"
+components = [ "clippy", "rustfmt", "rust-src"]
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index ffc49fd3b6..6de2a3476b 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -53,6 +53,7 @@ image = { workspace = true, features = [
itertools = { workspace = true }
lazy_static = { workspace = true }
mcp-types = { workspace = true }
+once_cell = { workspace = true }
path-clean = { workspace = true }
rand = { workspace = true }
ratatui = { workspace = true, features = [
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 3ac335345d..de3fc4bb1a 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -77,9 +77,11 @@ use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
-use crate::history_cell::RateLimitSnapshotDisplay;
use crate::markdown::append_markdown;
use crate::slash_command::SlashCommand;
+use crate::status::RateLimitSnapshotDisplay;
+use crate::status::new_status_output;
+use crate::status::rate_limit_snapshot_display;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
// streaming internals are provided by crate::streaming and crate::markdown_stream
@@ -91,6 +93,7 @@ use self::agent::spawn_agent;
use self::agent::spawn_agent_from_existing;
mod session_header;
use self::session_header::SessionHeader;
+use crate::streaming::controller::AppEventHistorySink;
use crate::streaming::controller::StreamController;
use std::path::Path;
@@ -251,13 +254,11 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio
impl ChatWidget {
fn flush_answer_stream_with_separator(&mut self) {
- if let Some(mut controller) = self.stream_controller.take()
- && let Some(cell) = controller.finalize()
- {
- self.add_boxed_history(cell);
+ if let Some(mut controller) = self.stream_controller.take() {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ controller.finalize(&sink);
}
}
-
// --- Small event handlers ---
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
self.bottom_pane
@@ -347,8 +348,12 @@ impl ChatWidget {
}
fn on_task_complete(&mut self, last_agent_message: Option) {
- // If a stream is currently active, finalize it.
- self.flush_answer_stream_with_separator();
+ // If a stream is currently active, finalize only that stream to flush any tail
+ // without emitting stray headers for other streams.
+ if let Some(mut controller) = self.stream_controller.take() {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ controller.finalize(&sink);
+ }
// Mark task stopped and request redraw now that all content is in history.
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
@@ -379,7 +384,7 @@ impl ChatWidget {
snapshot.primary.as_ref().map(|window| window.used_percent),
);
- let display = history_cell::rate_limit_snapshot_display(&snapshot, Local::now());
+ let display = rate_limit_snapshot_display(&snapshot, Local::now());
self.rate_limit_snapshot = Some(display);
if !warnings.is_empty() {
@@ -551,18 +556,14 @@ impl ChatWidget {
self.add_to_history(history_cell::new_stream_error_event(message));
self.request_redraw();
}
-
/// Periodic tick to commit at most one queued line to history with a small delay,
/// animating the output.
pub(crate) fn on_commit_tick(&mut self) {
if let Some(controller) = self.stream_controller.as_mut() {
- let (cell, is_idle) = controller.on_commit_tick();
- if let Some(cell) = cell {
- self.bottom_pane.set_task_running(false);
- self.add_boxed_history(cell);
- }
- if is_idle {
- self.app_event_tx.send(AppEvent::StopCommitAnimation);
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ let finished = controller.on_commit_tick(&sink);
+ if finished {
+ self.handle_stream_finished();
}
}
}
@@ -606,10 +607,9 @@ impl ChatWidget {
if self.stream_controller.is_none() {
self.stream_controller = Some(StreamController::new(self.config.clone()));
}
- if let Some(controller) = self.stream_controller.as_mut()
- && controller.push(&delta)
- {
- self.app_event_tx.send(AppEvent::StartCommitAnimation);
+ if let Some(controller) = self.stream_controller.as_mut() {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ controller.push_and_maybe_commit(&delta, &sink);
}
self.request_redraw();
}
@@ -1466,7 +1466,7 @@ impl ChatWidget {
default_usage = TokenUsage::default();
&default_usage
};
- self.add_to_history(history_cell::new_status_output(
+ self.add_to_history(new_status_output(
&self.config,
usage_ref,
&self.conversation_id,
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 5b6c4af3ee..c1e5f43378 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -11,30 +11,17 @@ use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use base64::Engine;
-use chrono::DateTime;
-use chrono::Duration as ChronoDuration;
-use chrono::Local;
use codex_ansi_escape::ansi_escape_line;
-use codex_common::create_config_summary_entries;
use codex_common::elapsed::format_duration;
-use codex_core::auth::get_auth_file;
-use codex_core::auth::try_read_auth_json;
use codex_core::config::Config;
use codex_core::config_types::ReasoningSummaryFormat;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
-use codex_core::project_doc::discover_project_doc_paths;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
-use codex_core::protocol::RateLimitSnapshot;
-use codex_core::protocol::RateLimitWindow;
-use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
-use codex_core::protocol::TokenUsage;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
-use codex_protocol::mcp_protocol::ConversationId;
-use codex_protocol::num_format::format_with_separators;
use codex_protocol::parse_command::ParsedCommand;
use image::DynamicImage;
use image::ImageReader;
@@ -51,7 +38,6 @@ use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::any::Any;
use std::collections::HashMap;
-use std::convert::TryFrom;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
@@ -60,10 +46,6 @@ use std::time::Instant;
use tracing::error;
use unicode_width::UnicodeWidthStr;
-const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
-const STATUS_LIMIT_BAR_FILLED: &str = "โ";
-const STATUS_LIMIT_BAR_EMPTY: &str = " ";
-
#[derive(Clone, Debug)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
@@ -225,6 +207,12 @@ pub(crate) struct PlainHistoryCell {
lines: Vec>,
}
+impl PlainHistoryCell {
+ pub(crate) fn new(lines: Vec>) -> Self {
+ Self { lines }
+ }
+}
+
impl HistoryCell for PlainHistoryCell {
fn display_lines(&self, _width: u16) -> Vec> {
self.lines.clone()
@@ -636,9 +624,9 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
}
const TOOL_CALL_MAX_LINES: usize = 5;
-const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
+pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 64; // Just an eyeballed value
-fn card_inner_width(width: u16, max_inner_width: usize) -> Option {
+pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option {
if width < 4 {
return None;
}
@@ -646,7 +634,7 @@ fn card_inner_width(width: u16, max_inner_width: usize) -> Option {
Some(inner_width)
}
-fn with_border(lines: Vec>) -> Vec> {
+pub(crate) fn with_border(lines: Vec>) -> Vec> {
let content_width = lines
.iter()
.map(|line| {
@@ -682,26 +670,29 @@ fn with_border(lines: Vec>) -> Vec> {
out
}
-fn title_case(s: &str) -> String {
- if s.is_empty() {
- return String::new();
- }
- let mut chars = s.chars();
- let first = match chars.next() {
- Some(c) => c,
- None => return String::new(),
+pub(crate) fn format_directory_display(directory: &Path, max_width: Option) -> String {
+ let formatted = if let Some(rel) = relativize_to_home(directory) {
+ if rel.as_os_str().is_empty() {
+ "~".to_string()
+ } else {
+ format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
+ }
+ } else {
+ directory.display().to_string()
};
- let rest: String = chars.as_str().to_ascii_lowercase();
- first.to_uppercase().collect::() + &rest
+
+ if let Some(max_width) = max_width {
+ if max_width == 0 {
+ return String::new();
+ }
+ if UnicodeWidthStr::width(formatted.as_str()) > max_width {
+ return crate::text_formatting::center_truncate_path(&formatted, max_width);
+ }
+ }
+
+ formatted
}
-fn pretty_provider_name(id: &str) -> String {
- if id.eq_ignore_ascii_case("openai") {
- "OpenAI".to_string()
- } else {
- title_case(id)
- }
-}
/// Return the emoji followed by a hair space (U+200A).
/// Using only the hair space avoids excessive padding after the emoji while
/// still providing a small visual gap across terminals.
@@ -827,30 +818,12 @@ impl SessionHeaderHistoryCell {
}
fn format_directory(&self, max_width: Option) -> String {
- Self::format_directory_inner(&self.directory, max_width)
+ format_directory_display(&self.directory, max_width)
}
+ #[cfg(test)]
fn format_directory_inner(directory: &Path, max_width: Option) -> String {
- let formatted = if let Some(rel) = relativize_to_home(directory) {
- if rel.as_os_str().is_empty() {
- "~".to_string()
- } else {
- format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
- }
- } else {
- directory.display().to_string()
- };
-
- if let Some(max_width) = max_width {
- if max_width == 0 {
- return String::new();
- }
- if UnicodeWidthStr::width(formatted.as_str()) > max_width {
- return crate::text_formatting::center_truncate_path(&formatted, max_width);
- }
- }
-
- formatted
+ format_directory_display(directory, max_width)
}
fn reasoning_label(&self) -> Option<&'static str> {
@@ -871,7 +844,6 @@ impl HistoryCell for SessionHeaderHistoryCell {
let make_row = |spans: Vec>| Line::from(spans);
- // Title line rendered inside the box: ">_ OpenAI Codex (vX)"
let title_spans: Vec> = vec![
Span::from(">_ ").dim(),
Span::from("OpenAI Codex").bold(),
@@ -924,6 +896,12 @@ pub(crate) struct CompositeHistoryCell {
parts: Vec>,
}
+impl CompositeHistoryCell {
+ pub(crate) fn new(parts: Vec>) -> Self {
+ Self { parts }
+ }
+}
+
impl HistoryCell for CompositeHistoryCell {
fn display_lines(&self, width: u16) -> Vec> {
let mut out: Vec> = Vec::new();
@@ -1083,228 +1061,6 @@ pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell {
}
}
-#[derive(Debug, Clone)]
-pub(crate) struct RateLimitWindowDisplay {
- pub used_percent: f64,
- pub resets_at: Option,
-}
-
-impl RateLimitWindowDisplay {
- fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self {
- let resets_at = window
- .resets_in_seconds
- .and_then(|seconds| i64::try_from(seconds).ok())
- .and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs)))
- .map(|dt| dt.format("%b %-d, %Y %-I:%M %p").to_string());
-
- Self {
- used_percent: window.used_percent,
- resets_at,
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub(crate) struct RateLimitSnapshotDisplay {
- pub primary: Option,
- pub secondary: Option,
-}
-
-pub(crate) fn rate_limit_snapshot_display(
- snapshot: &RateLimitSnapshot,
- captured_at: DateTime,
-) -> RateLimitSnapshotDisplay {
- RateLimitSnapshotDisplay {
- primary: snapshot
- .primary
- .as_ref()
- .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
- secondary: snapshot
- .secondary
- .as_ref()
- .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
- }
-}
-
-pub(crate) fn new_status_output(
- config: &Config,
- usage: &TokenUsage,
- session_id: &Option,
- rate_limits: Option<&RateLimitSnapshotDisplay>,
-) -> PlainHistoryCell {
- let mut lines: Vec> = Vec::new();
- lines.push("/status".magenta().into());
-
- let config_entries = create_config_summary_entries(config);
- let lookup = |k: &str| -> String {
- config_entries
- .iter()
- .find(|(key, _)| *key == k)
- .map(|(_, v)| v.clone())
- .unwrap_or_default()
- };
-
- // ๐ Workspace
- lines.push(vec![padded_emoji("๐").into(), "Workspace".bold()].into());
- // Path (home-relative, e.g., ~/code/project)
- let cwd_str = match relativize_to_home(&config.cwd) {
- Some(rel) if !rel.as_os_str().is_empty() => {
- let sep = std::path::MAIN_SEPARATOR;
- format!("~{sep}{}", rel.display())
- }
- Some(_) => "~".to_string(),
- None => config.cwd.display().to_string(),
- };
- lines.push(vec![" โข Path: ".into(), cwd_str.into()].into());
- // Approval mode (as-is)
- lines.push(vec![" โข Approval Mode: ".into(), lookup("approval").into()].into());
- // Sandbox (simplified name only)
- let sandbox_name = match &config.sandbox_policy {
- SandboxPolicy::DangerFullAccess => "danger-full-access",
- SandboxPolicy::ReadOnly => "read-only",
- SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
- };
- lines.push(vec![" โข Sandbox: ".into(), sandbox_name.into()].into());
-
- // AGENTS.md files discovered via core's project_doc logic
- let agents_list = {
- match discover_project_doc_paths(config) {
- Ok(paths) => {
- let mut rels: Vec = Vec::new();
- for p in paths {
- let display = if let Some(parent) = p.parent() {
- if parent == config.cwd {
- "AGENTS.md".to_string()
- } else {
- let mut cur = config.cwd.as_path();
- let mut ups = 0usize;
- let mut reached = false;
- while let Some(c) = cur.parent() {
- if cur == parent {
- reached = true;
- break;
- }
- cur = c;
- ups += 1;
- }
- if reached {
- let up = format!("..{}", std::path::MAIN_SEPARATOR);
- format!("{}AGENTS.md", up.repeat(ups))
- } else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
- stripped.display().to_string()
- } else {
- p.display().to_string()
- }
- }
- } else {
- p.display().to_string()
- };
- rels.push(display);
- }
- rels
- }
- Err(_) => Vec::new(),
- }
- };
- if agents_list.is_empty() {
- lines.push(" โข AGENTS files: (none)".into());
- } else {
- lines.push(vec![" โข AGENTS files: ".into(), agents_list.join(", ").into()].into());
- }
- lines.push("".into());
-
- // ๐ค Account (only if ChatGPT tokens exist), shown under the first block
- let auth_file = get_auth_file(&config.codex_home);
- let auth = try_read_auth_json(&auth_file).ok();
- let is_chatgpt_auth = auth
- .as_ref()
- .and_then(|auth| auth.tokens.as_ref())
- .is_some();
- if is_chatgpt_auth
- && let Some(auth) = auth.as_ref()
- && let Some(tokens) = auth.tokens.clone()
- {
- lines.push(vec![padded_emoji("๐ค").into(), "Account".bold()].into());
- lines.push(" โข Signed in with ChatGPT".into());
-
- let info = tokens.id_token;
- if let Some(email) = &info.email {
- lines.push(vec![" โข Login: ".into(), email.clone().into()].into());
- }
-
- match auth.openai_api_key.as_deref() {
- Some(key) if !key.is_empty() => {
- lines.push(" โข Using API key. Run codex login to use ChatGPT plan".into());
- }
- _ => {
- let plan_text = info
- .get_chatgpt_plan_type()
- .map(|s| title_case(&s))
- .unwrap_or_else(|| "Unknown".to_string());
- lines.push(vec![" โข Plan: ".into(), plan_text.into()].into());
- }
- }
-
- lines.push("".into());
- }
-
- // ๐ง Model
- lines.push(vec![padded_emoji("๐ง ").into(), "Model".bold()].into());
- lines.push(vec![" โข Name: ".into(), config.model.clone().into()].into());
- let provider_disp = pretty_provider_name(&config.model_provider_id);
- lines.push(vec![" โข Provider: ".into(), provider_disp.into()].into());
- // Only show Reasoning fields if present in config summary
- let reff = lookup("reasoning effort");
- if !reff.is_empty() {
- lines.push(vec![" โข Reasoning Effort: ".into(), title_case(&reff).into()].into());
- }
- let rsum = lookup("reasoning summaries");
- if !rsum.is_empty() {
- lines.push(vec![" โข Reasoning Summaries: ".into(), title_case(&rsum).into()].into());
- }
-
- lines.push("".into());
-
- // ๐ป Client
- let cli_version = crate::version::CODEX_CLI_VERSION;
- lines.push(vec![padded_emoji("๐ป").into(), "Client".bold()].into());
- lines.push(vec![" โข CLI Version: ".into(), cli_version.into()].into());
- lines.push("".into());
-
- // ๐ Token Usage
- lines.push(vec!["๐ ".into(), "Token Usage".bold()].into());
- if let Some(session_id) = session_id {
- lines.push(vec![" โข Session ID: ".into(), session_id.to_string().into()].into());
- }
- // Input: [+ cached]
- let mut input_line_spans: Vec> = vec![
- " โข Input: ".into(),
- format_with_separators(usage.non_cached_input()).into(),
- ];
- if usage.cached_input_tokens > 0 {
- let cached = usage.cached_input_tokens;
- input_line_spans.push(format!(" (+ {cached} cached)").into());
- }
- lines.push(Line::from(input_line_spans));
- // Output: