diff --git a/.bazelrc b/.bazelrc
index 603ad46e4..cc59037b5 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -136,6 +136,16 @@ common:ci-linux --config=remote
common:ci-linux --strategy=remote
common:ci-linux --platforms=//:rbe
+# Fork-only Linux jobs that must stay on Bazel but cannot rely on BuildBuddy
+# remote execution should use this explicit local variant instead of trying to
+# partially undo `ci-linux` at the callsite.
+common:ci-linux-local --config=ci-bazel
+common:ci-linux-local --build_metadata=TAG_os=linux
+common:ci-linux-local --extra_execution_platforms=
+common:ci-linux-local --platforms=//:local_linux
+common:ci-linux-local --spawn_strategy=local
+common:ci-linux-local --strategy=local
+
# On mac, we can run all the build actions remotely but test actions locally.
common:ci-macos --config=ci-bazel
common:ci-macos --build_metadata=TAG_os=macos
diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh
index 9c95fda15..301e9b2ca 100755
--- a/.github/scripts/run-bazel-ci.sh
+++ b/.github/scripts/run-bazel-ci.sh
@@ -65,6 +65,11 @@ case "${RUNNER_OS:-}" in
;;
esac
+force_local_ci_config=
+if [[ "${RUNNER_OS:-}" == "Linux" ]]; then
+ force_local_ci_config=ci-linux-local
+fi
+
print_bazel_test_log_tails() {
local console_log="$1"
local testlogs_dir
@@ -210,7 +215,29 @@ if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_cmd+=("${bazel_startup_args[@]}")
fi
-if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
+if [[ "${CODEX_BAZEL_FORCE_LOCAL:-0}" == "1" ]]; then
+ echo "CODEX_BAZEL_FORCE_LOCAL=1; using local Bazel configuration."
+ bazel_run_args=(
+ "${bazel_args[@]}"
+ --remote_cache=
+ --remote_executor=
+ )
+ if [[ -n "${force_local_ci_config}" ]]; then
+ bazel_run_args+=("--config=${force_local_ci_config}")
+ fi
+ if (( ${#post_config_bazel_args[@]} > 0 )); then
+ bazel_run_args+=("${post_config_bazel_args[@]}")
+ fi
+ set +e
+ run_bazel "${bazel_cmd[@]:1}" \
+ --noexperimental_remote_repo_contents_cache \
+ "${bazel_run_args[@]}" \
+ -- \
+ "${bazel_targets[@]}" \
+ 2>&1 | tee "$bazel_console_log"
+ bazel_status=${PIPESTATUS[0]}
+ set -e
+elif [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
echo "BuildBuddy API key is available; using remote Bazel configuration."
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
# seen in CI (for example "is not a symlink" or permission errors while
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index d14817f00..66a92a5e8 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -1,6 +1,7 @@
# Workflow Strategy
-The workflows in this directory are split so that pull requests get fast, review-friendly signal while `main` still gets the full cross-platform verification pass.
+This fork keeps pushes to `main` quiet. Heavier validation runs stay available through
+`workflow_dispatch`, while pull requests can still use targeted review-time checks.
## Pull Requests
@@ -14,18 +15,23 @@ The workflows in this directory are split so that pull requests get fast, review
- `argument-comment-lint` on Linux, macOS, and Windows
- `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes
-## Post-Merge On `main`
+## Manual Verification
-- `bazel.yml` also runs on pushes to `main`.
- This re-verifies the merged Bazel path and helps keep the BuildBuddy caches warm.
+- `bazel.yml` is available as a manual verification path when the fork needs a full
+ Bazel pass.
- `rust-ci-full.yml` is the full Cargo-native verification workflow.
- It keeps the heavier checks off the PR path while still validating them after merge:
+ It keeps the heavier checks off the PR path while still providing an on-demand
+ validation path:
- the full Cargo `clippy` matrix
- the full Cargo `nextest` matrix
- release-profile Cargo builds
- cross-platform `argument-comment-lint`
- Linux remote-env tests
+Other repo-level checks that used to run on `push(main)` in upstream, such as
+`ci.yml`, `cargo-deny.yml`, `codespell.yml`, `sdk.yml`, and `v8-canary.yml`, are also
+manual-only in this fork so routine sync pushes do not fan out into unrelated CI.
+
## Rule Of Thumb
- If a build/test/clippy check can be expressed in Bazel, prefer putting the PR-time version in `bazel.yml`.
diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml
index eeefcdada..307780546 100644
--- a/.github/workflows/bazel.yml
+++ b/.github/workflows/bazel.yml
@@ -5,9 +5,6 @@ name: Bazel
on:
pull_request: {}
- push:
- branches:
- - main
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml
index 5294d0c7c..b118046b5 100644
--- a/.github/workflows/cargo-deny.yml
+++ b/.github/workflows/cargo-deny.yml
@@ -2,9 +2,7 @@ name: cargo-deny
on:
pull_request:
- push:
- branches:
- - main
+ workflow_dispatch:
jobs:
cargo-deny:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 76a8aa014..2ca07b2b0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: ci
on:
pull_request: {}
- push: { branches: [main] }
+ workflow_dispatch:
jobs:
build-test:
@@ -37,6 +37,7 @@ jobs:
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Stage npm package
+ if: ${{ github.repository == 'openai/codex' }}
id: stage_npm_package
env:
GH_TOKEN: ${{ github.token }}
@@ -53,6 +54,7 @@ jobs:
echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT"
- name: Upload staged npm package artifact
+ if: ${{ github.repository == 'openai/codex' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: codex-npm-staging
diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml
index 8e9f701ee..11815a01e 100644
--- a/.github/workflows/codespell.yml
+++ b/.github/workflows/codespell.yml
@@ -3,10 +3,9 @@
name: Codespell
on:
- push:
- branches: [main]
pull_request:
branches: [main]
+ workflow_dispatch:
permissions:
contents: read
diff --git a/.github/workflows/pypi-release-artifacts.yml b/.github/workflows/pypi-release-artifacts.yml
new file mode 100644
index 000000000..ea176a2ed
--- /dev/null
+++ b/.github/workflows/pypi-release-artifacts.yml
@@ -0,0 +1,165 @@
+name: pypi-release-artifacts
+
+on:
+ workflow_call:
+ inputs:
+ checkout_ref:
+ required: true
+ type: string
+ release_tag:
+ required: true
+ type: string
+
+concurrency:
+ group: pypi-release-artifacts-${{ inputs.release_tag }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: build-artifact-${{ matrix.target }}
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ env:
+ CARGO_INCREMENTAL: "0"
+ RUSTC_WRAPPER: ""
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: macos-14
+ target: aarch64-apple-darwin
+ runtime_binary_name: codex
+ release_asset_name: codex-aarch64-apple-darwin.tar.gz
+ build_args: --bin codex
+ - runner: macos-15-intel
+ target: x86_64-apple-darwin
+ runtime_binary_name: codex
+ release_asset_name: codex-x86_64-apple-darwin.tar.gz
+ build_args: --bin codex
+ - runner: ubuntu-24.04
+ target: x86_64-unknown-linux-gnu
+ runtime_binary_name: codex
+ release_asset_name: codex-x86_64-unknown-linux-gnu.tar.gz
+ build_args: --bin codex
+ - runner: windows-2022
+ target: x86_64-pc-windows-msvc
+ runtime_binary_name: codex.exe
+ release_asset_name: codex-x86_64-pc-windows-msvc.zip
+ build_args: --bin codex --bin codex-windows-sandbox-setup --bin codex-command-runner
+ steps:
+ - name: Checkout release ref
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.checkout_ref }}
+
+ - uses: dtolnay/rust-toolchain@1.93.0
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Clear workspace build rustflags for CI release builds
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "RUSTFLAGS=" >> "$GITHUB_ENV"
+ echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV"
+ echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV"
+
+ - name: Use default macOS linker
+ if: ${{ runner.os == 'macOS' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "RUSTFLAGS=" >> "$GITHUB_ENV"
+ echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV"
+ echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV"
+ echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV"
+ echo "CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV"
+
+ - name: Install Linux build dependencies
+ if: ${{ runner.os == 'Linux' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install -y libcap-dev pkg-config protobuf-compiler
+
+ - name: Install macOS build dependencies
+ if: ${{ runner.os == 'macOS' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ if ! command -v protoc >/dev/null 2>&1; then
+ brew install protobuf
+ fi
+
+ - name: Install Windows build dependencies
+ if: ${{ runner.os == 'Windows' }}
+ shell: pwsh
+ run: |
+ if (-not (Get-Command protoc -ErrorAction SilentlyContinue)) {
+ choco install protoc -y --no-progress
+ }
+
+ - name: Build release binaries
+ shell: bash
+ working-directory: codex-rs
+ run: |
+ set -euo pipefail
+ cargo build --locked --release --target "${{ matrix.target }}" ${{ matrix.build_args }}
+
+ - name: Stage runtime artifact
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p "${RUNNER_TEMP}/runtime-artifact"
+ cp "codex-rs/target/${{ matrix.target }}/release/${{ matrix.runtime_binary_name }}" \
+ "${RUNNER_TEMP}/runtime-artifact/${{ matrix.runtime_binary_name }}"
+
+ - name: Stage release asset
+ shell: bash
+ run: |
+ set -euo pipefail
+ release_dir="${RUNNER_TEMP}/release-asset"
+ mkdir -p "${release_dir}"
+
+ if [[ "${{ runner.os }}" == "Windows" ]]; then
+ cp "codex-rs/target/${{ matrix.target }}/release/codex.exe" "${release_dir}/codex.exe"
+ cp "codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe" "${release_dir}/codex-windows-sandbox-setup.exe"
+ cp "codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" "${release_dir}/codex-command-runner.exe"
+ else
+ cp "codex-rs/target/${{ matrix.target }}/release/codex" "${release_dir}/codex"
+ fi
+
+ - name: Archive Unix release asset
+ if: ${{ runner.os != 'Windows' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ release_dir="${RUNNER_TEMP}/release-asset"
+ archive_dir="${RUNNER_TEMP}/release-archive"
+ mkdir -p "${archive_dir}"
+ tar -C "${release_dir}" -czf "${archive_dir}/${{ matrix.release_asset_name }}" codex
+
+ - name: Archive Windows release asset
+ if: ${{ runner.os == 'Windows' }}
+ shell: pwsh
+ run: |
+ $releaseDir = Join-Path $env:RUNNER_TEMP "release-asset"
+ $archiveDir = Join-Path $env:RUNNER_TEMP "release-archive"
+ New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
+ Compress-Archive -Path (Join-Path $releaseDir '*') -DestinationPath (Join-Path $archiveDir '${{ matrix.release_asset_name }}') -Force
+
+ - name: Upload runtime binary artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: pypi-runtime-${{ matrix.target }}
+ path: ${{ runner.temp }}/runtime-artifact/*
+ if-no-files-found: error
+
+ - name: Upload GitHub release asset
+ uses: actions/upload-artifact@v4
+ with:
+ name: pypi-release-asset-${{ matrix.target }}
+ path: ${{ runner.temp }}/release-archive/*
+ if-no-files-found: error
diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml
new file mode 100644
index 000000000..c182054ce
--- /dev/null
+++ b/.github/workflows/pypi-release.yml
@@ -0,0 +1,278 @@
+name: pypi-release
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+ workflow_dispatch:
+ inputs:
+ release_tag:
+ description: Existing tag to build and publish, for example v0.1.12
+ required: true
+ type: string
+ artifact_run_id:
+ description: Existing pypi-release run id whose wheel and release artifacts should be reused
+ required: false
+ type: string
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref_name || inputs.release_tag }}
+ cancel-in-progress: true
+
+jobs:
+ prepare:
+ runs-on: ubuntu-latest
+ outputs:
+ release_tag: ${{ steps.meta.outputs.release_tag }}
+ release_version: ${{ steps.meta.outputs.release_version }}
+ checkout_ref: ${{ steps.meta.outputs.checkout_ref }}
+ artifact_run_id: ${{ steps.meta.outputs.artifact_run_id }}
+ reuse_artifacts: ${{ steps.meta.outputs.reuse_artifacts }}
+ steps:
+ - name: Resolve release metadata
+ id: meta
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
+ release_tag="${{ inputs.release_tag }}"
+ checkout_ref="refs/tags/${release_tag}"
+ else
+ release_tag="${GITHUB_REF_NAME}"
+ checkout_ref="${GITHUB_REF}"
+ fi
+
+ [[ "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \
+ || { echo "Release tag ${release_tag} is not in the expected format."; exit 1; }
+
+ echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT"
+ echo "release_version=${release_tag#v}" >> "$GITHUB_OUTPUT"
+ echo "checkout_ref=${checkout_ref}" >> "$GITHUB_OUTPUT"
+ artifact_run_id="${{ inputs.artifact_run_id }}"
+ if [[ -n "${artifact_run_id}" ]]; then
+ echo "artifact_run_id=${artifact_run_id}" >> "$GITHUB_OUTPUT"
+ echo "reuse_artifacts=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "artifact_run_id=" >> "$GITHUB_OUTPUT"
+ echo "reuse_artifacts=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Checkout release ref
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ steps.meta.outputs.checkout_ref }}
+
+ - name: Validate tag matches codex-enhanced runtime version
+ shell: bash
+ run: |
+ set -euo pipefail
+ runtime_ver="$(grep -m1 '^version' sdk/python-runtime-enhanced/pyproject.toml | sed -E 's/version *= *"([^"]+)".*/\1/')"
+ tag_ver="${{ steps.meta.outputs.release_version }}"
+ [[ "${tag_ver}" == "${runtime_ver}" ]] \
+ || { echo "Tag version ${tag_ver} does not match sdk/python-runtime-enhanced ${runtime_ver}."; exit 1; }
+
+ release-assets:
+ needs: prepare
+ if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }}
+ uses: ./.github/workflows/pypi-release-artifacts.yml
+ with:
+ checkout_ref: ${{ needs.prepare.outputs.checkout_ref }}
+ release_tag: ${{ needs.prepare.outputs.release_tag }}
+
+ build:
+ needs:
+ - prepare
+ - release-assets
+ name: build-wheel-${{ matrix.target }}
+ if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' }}
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: macos-14
+ target: aarch64-apple-darwin
+ runtime_artifact_name: pypi-runtime-aarch64-apple-darwin
+ binary_name: codex
+ - runner: macos-15-intel
+ target: x86_64-apple-darwin
+ runtime_artifact_name: pypi-runtime-x86_64-apple-darwin
+ binary_name: codex
+ - runner: ubuntu-24.04
+ target: x86_64-unknown-linux-gnu
+ runtime_artifact_name: pypi-runtime-x86_64-unknown-linux-gnu
+ binary_name: codex
+ - runner: windows-2022
+ target: x86_64-pc-windows-msvc
+ runtime_artifact_name: pypi-runtime-x86_64-pc-windows-msvc
+ binary_name: codex.exe
+ steps:
+ - name: Checkout release ref
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare.outputs.checkout_ref }}
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install Python build dependencies
+ shell: bash
+ run: python -m pip install --upgrade build hatchling packaging
+
+ - name: Download release binary artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ matrix.runtime_artifact_name }}
+ path: ${{ runner.temp }}/runtime-artifact
+
+ - name: Stage codex-enhanced runtime package
+ shell: bash
+ run: |
+ set -euo pipefail
+ python sdk/python/scripts/update_sdk_artifacts.py \
+ stage-runtime \
+ "${RUNNER_TEMP}/codex-enhanced" \
+ "${RUNNER_TEMP}/runtime-artifact/${{ matrix.binary_name }}" \
+ --runtime-version "${{ needs.prepare.outputs.release_version }}" \
+ --runtime-package enhanced
+
+ - name: Build wheel
+ shell: bash
+ run: python -m build --wheel "${RUNNER_TEMP}/codex-enhanced"
+
+ - name: Upload wheel artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: pypi-wheel-${{ matrix.target }}
+ path: ${{ runner.temp }}/codex-enhanced/dist/*
+ if-no-files-found: error
+
+ publish:
+ needs:
+ - prepare
+ - release-assets
+ - build
+ if: ${{ always() && needs.prepare.result == 'success' && ((needs.prepare.outputs.reuse_artifacts == 'true' && needs.build.result == 'skipped') || (needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' && needs.build.result == 'success')) }}
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ id-token: write
+ steps:
+ - name: Download wheel artifacts from current run
+ if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }}
+ uses: actions/download-artifact@v4
+ with:
+ pattern: pypi-wheel-*
+ path: dist
+ merge-multiple: true
+
+ - name: Download wheel artifacts from an earlier run
+ if: ${{ needs.prepare.outputs.reuse_artifacts == 'true' }}
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ mkdir -p dist
+ work_dir="$(mktemp -d)"
+ gh run download "${{ needs.prepare.outputs.artifact_run_id }}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --dir "${work_dir}"
+ find "${work_dir}" -type f -name '*.whl' -exec mv {} dist/ \;
+ if ! find dist -maxdepth 1 -type f -name '*.whl' | grep -q .; then
+ echo "No wheel artifacts were downloaded from run ${{ needs.prepare.outputs.artifact_run_id }}."
+ exit 1
+ fi
+
+ - name: Publish codex-enhanced wheels to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: dist
+ skip-existing: true
+ verbose: true
+
+ github-release:
+ needs:
+ - prepare
+ - release-assets
+ - build
+ - publish
+ if: ${{ always() && needs.prepare.result == 'success' && needs.publish.result == 'success' && ((needs.prepare.outputs.reuse_artifacts == 'true' && needs.release-assets.result == 'skipped' && needs.build.result == 'skipped') || (needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' && needs.build.result == 'success')) }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout release ref
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare.outputs.checkout_ref }}
+
+ - name: Download release assets from current run
+ if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }}
+ uses: actions/download-artifact@v4
+ with:
+ pattern: pypi-release-asset-*
+ path: release-dist
+ merge-multiple: true
+
+ - name: Download wheel artifacts from current run
+ if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }}
+ uses: actions/download-artifact@v4
+ with:
+ pattern: pypi-wheel-*
+ path: release-dist
+ merge-multiple: true
+
+ - name: Download release assets and wheels from an earlier run
+ if: ${{ needs.prepare.outputs.reuse_artifacts == 'true' }}
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ mkdir -p release-dist
+ work_dir="$(mktemp -d)"
+ gh run download "${{ needs.prepare.outputs.artifact_run_id }}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --dir "${work_dir}"
+ find "${work_dir}" -type f \( -name '*.whl' -o -name '*.tar.gz' -o -name '*.zip' \) -exec mv {} release-dist/ \;
+ if ! find release-dist -maxdepth 1 -type f | grep -q .; then
+ echo "No release assets were downloaded from run ${{ needs.prepare.outputs.artifact_run_id }}."
+ exit 1
+ fi
+
+ - name: Generate GitHub Release notes
+ shell: bash
+ env:
+ RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }}
+ run: |
+ set -euo pipefail
+ commit="$(git rev-parse HEAD^{commit})"
+ notes_path="${RUNNER_TEMP}/release-notes.md"
+ pypi_url="https://pypi.org/project/codex-enhanced/${{ needs.prepare.outputs.release_version }}/"
+
+ git log -1 --format=%B "${commit}" > "${notes_path}"
+ echo >> "${notes_path}"
+ {
+ echo "## PyPI"
+ echo
+ echo "- https://pypi.org/project/codex-enhanced/"
+ echo "- ${pypi_url}"
+ } >> "${notes_path}"
+
+ echo "RELEASE_NOTES_PATH=${notes_path}" >> "$GITHUB_ENV"
+
+ - name: Publish GitHub Release
+ uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
+ with:
+ name: ${{ needs.prepare.outputs.release_version }}
+ tag_name: ${{ needs.prepare.outputs.release_tag }}
+ body_path: ${{ env.RELEASE_NOTES_PATH }}
+ files: release-dist/*
+ overwrite_files: true
+ prerelease: ${{ contains(needs.prepare.outputs.release_version, '-') }}
diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml
index 146e57524..5368ceca5 100644
--- a/.github/workflows/rust-ci-full.yml
+++ b/.github/workflows/rust-ci-full.yml
@@ -1,8 +1,5 @@
name: rust-ci-full
on:
- push:
- branches:
- - main
workflow_dispatch:
# CI builds in debug (dev) for faster signal.
diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml
index 3a9eadc8b..0065827eb 100644
--- a/.github/workflows/rust-ci.yml
+++ b/.github/workflows/rust-ci.yml
@@ -134,19 +134,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- include:
- - name: Linux
- runner: ubuntu-24.04
- timeout_minutes: 30
- - name: macOS
- runner: macos-15-xlarge
- timeout_minutes: 30
- - name: Windows
- runner: windows-x64
- timeout_minutes: 30
- runs_on:
- group: codex-runners
- labels: codex-windows-x64
+ include: ${{ fromJSON(github.repository == 'openai/codex' && '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":30},{"name":"macOS","runner":"macos-15-xlarge","timeout_minutes":30},{"name":"Windows","runner":"windows-x64","timeout_minutes":30,"runs_on":{"group":"codex-runners","labels":"codex-windows-x64"}}]' || '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":120}]') }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-bazel-ci
@@ -158,9 +146,60 @@ jobs:
shell: bash
run: |
sudo DEBIAN_FRONTEND=noninteractive apt-get update
- sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
+ sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev protobuf-compiler libprotobuf-dev
+ - name: Run argument comment lint on codex-rs via prebuilt wrapper
+ if: ${{ runner.os == 'Linux' && github.repository != 'openai/codex' }}
+ shell: bash
+ run: |
+ heartbeat() {
+ while true; do
+ sleep 60
+ echo "argument comment lint still running via prebuilt wrapper ($(date -u '+%Y-%m-%dT%H:%M:%SZ'))"
+ done
+ }
+
+ heartbeat &
+ heartbeat_pid=$!
+ cleanup() {
+ kill "$heartbeat_pid" 2>/dev/null || true
+ }
+ trap cleanup EXIT
+
+ rustup toolchain install nightly-2025-09-18 \
+ --profile minimal \
+ --component llvm-tools-preview \
+ --component rustc-dev \
+ --component rust-src \
+ --no-self-update
+
+ set +e
+ timeout --foreground --signal=TERM --kill-after=30s 110m \
+ ./tools/argument-comment-lint/run-prebuilt-linter.py
+ status=$?
+ set -e
+
+ if [[ $status -eq 124 ]]; then
+ echo "argument comment lint prebuilt-wrapper step exceeded 110 minutes; failing explicitly before the 120-minute job timeout so shared CI follow-up can diagnose it."
+ fi
+
+ exit "$status"
+ - name: Run argument comment lint on codex-rs via Bazel
+ if: ${{ runner.os == 'Linux' && github.repository == 'openai/codex' }}
+ env:
+ BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
+ shell: bash
+ run: |
+ bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)"
+ ./.github/scripts/run-bazel-ci.sh \
+ -- \
+ build \
+ --config=argument-comment-lint \
+ --keep_going \
+ --build_metadata=COMMIT_SHA=${GITHUB_SHA} \
+ -- \
+ ${bazel_targets}
- name: Run argument comment lint on codex-rs via Bazel
- if: ${{ runner.os != 'Windows' }}
+ if: ${{ runner.os == 'macOS' }}
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml
index 45c983ac1..0da6755fc 100644
--- a/.github/workflows/sdk.yml
+++ b/.github/workflows/sdk.yml
@@ -1,9 +1,8 @@
name: sdk
on:
- push:
- branches: [main]
pull_request: {}
+ workflow_dispatch:
jobs:
sdks:
diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml
index 0dc7dc005..38ceaad87 100644
--- a/.github/workflows/v8-canary.yml
+++ b/.github/workflows/v8-canary.yml
@@ -12,19 +12,6 @@ on:
- "patches/BUILD.bazel"
- "patches/v8_*.patch"
- "third_party/v8/**"
- push:
- branches:
- - main
- paths:
- - ".github/scripts/rusty_v8_bazel.py"
- - ".github/workflows/rusty-v8-release.yml"
- - ".github/workflows/v8-canary.yml"
- - "MODULE.bazel"
- - "MODULE.bazel.lock"
- - "codex-rs/Cargo.toml"
- - "patches/BUILD.bazel"
- - "patches/v8_*.patch"
- - "third_party/v8/**"
workflow_dispatch:
concurrency:
diff --git a/.gitignore b/.gitignore
index 82269594b..993277dc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,3 +92,8 @@ CHANGELOG.ignore.md
__pycache__/
*.pyc
+.codex/
+.desloppify/
+scorecard.png
+site/
+docs/assets/
diff --git a/AGENTS.md b/AGENTS.md
index c8d989fe9..f13d07d29 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,20 +16,13 @@ In the codex-rs folder where the rust code lives:
- Use an exact `/*param_name*/` comment before opaque literal arguments such as `None`, booleans, and numeric literals when passing them by position.
- Do not add these comments for string or char literals unless the comment adds real clarity; those literals are intentionally exempt from the lint.
- The parameter name in the comment must exactly match the callee signature.
- - You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not.
+ - Do not run bazel related commands unless in CI environement
+ - Do not run 'just argument-comment-lint' unless in CI environment
- When possible, make `match` statements exhaustive and avoid wildcard arms.
- Newly added traits should include doc comments that explain their role and how implementations are expected to use them.
- When writing tests, prefer comparing the equality of entire objects over fields one by one.
- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable.
- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`.
-- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the
- repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change.
-- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught
- locally before CI.
-- Bazel does not automatically make source-tree files available to compile-time Rust file access. If
- you add `include_str!`, `include_bytes!`, `sqlx::migrate!`, or similar build-time file or
- directory reads, update the crate's `BUILD.bazel` (`compile_data`, `build_script_data`, or test
- data) or Bazel may fail even when Cargo passes.
- Do not create small helper methods that are referenced only once.
- Avoid large modules:
- Prefer adding new modules instead of growing existing ones.
@@ -207,3 +200,7 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially:
- Validate with `cargo test -p codex-app-server-protocol`.
- Avoid boilerplate tests that only assert experimental field markers for individual
request fields in `common.rs`; rely on schema generation/tests and behavioral coverage instead.
+- cargo run --bin codex -- -p 总是 加 -p pig ; it allows api usage
+- 修改代码后总是执行 cargo build -p codex-cli; 然后执行交互式终端 (PTY) 验证要完成的特性是否实现;
+- 在跑 codex 命令之前, 总是用sudo codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime 来给二进制签名
+- 当需求改变的时候, 要同步更新对应的文档
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index 1e2123ec4..7ef01da37 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -996,6 +996,7 @@
"lalrpop_0.19.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ascii-canvas\",\"req\":\"^3.0\"},{\"default_features\":false,\"name\":\"bit-set\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"name\":\"diff\",\"req\":\"^0.1.12\"},{\"default_features\":false,\"name\":\"ena\",\"req\":\"^0.14\"},{\"name\":\"is-terminal\",\"req\":\"^0.4.2\"},{\"default_features\":false,\"features\":[\"use_std\"],\"name\":\"itertools\",\"req\":\"^0.10\"},{\"name\":\"lalrpop-util\",\"req\":\"^0.19.12\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"pico-args\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-case\",\"unicode-perl\"],\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"unicode-case\",\"unicode-perl\"],\"kind\":\"dev\",\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"string_cache\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"term\",\"req\":\"^0.7\"},{\"features\":[\"sha3\"],\"name\":\"tiny-keccak\",\"req\":\"^2.0.2\"},{\"default_features\":false,\"name\":\"unicode-xid\",\"req\":\"^0.2\"}],\"features\":{\"default\":[\"lexer\"],\"lexer\":[\"lalrpop-util/lexer\"],\"test\":[]}}",
"landlock_0.4.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"enumflags2\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{}}",
"language-tags_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}",
+ "lark-websocket-protobuf_0.1.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.6.0\"},{\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"kind\":\"build\",\"name\":\"prost-build\",\"req\":\"^0.12.6\"}],\"features\":{}}",
"lazy_static_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"once\"],\"name\":\"spin\",\"optional\":true,\"req\":\"^0.9.8\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"spin_no_std\":[\"spin\"]}}",
"leb128fmt_0.1.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}",
"libc_0.2.182": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}",
@@ -1103,6 +1104,26 @@
"onig_6.5.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"onig_sys\",\"req\":\"^69.9.1\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"onig_sys/generate\"],\"posix-api\":[\"onig_sys/posix-api\"],\"print-debug\":[\"onig_sys/print-debug\"],\"std-pattern\":[]}}",
"onig_sys_69.9.1": "{\"dependencies\":[{\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.16\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"bindgen\"],\"posix-api\":[],\"print-debug\":[]}}",
"opaque-debug_0.3.1": "{\"dependencies\":[],\"features\":{}}",
+ "openlark-ai_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"}],\"features\":{\"default\":[\"v1\"],\"full\":[\"v1\"],\"v1\":[]}}",
+ "openlark-analytics_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-analytics\":[\"full\"],\"analytics\":[\"search\",\"report\"],\"core\":[],\"default\":[\"search\",\"report\"],\"full\":[\"search\",\"report\",\"v4\"],\"report\":[\"report-core\",\"core\"],\"report-core\":[],\"search\":[\"search-core\",\"core\"],\"search-core\":[],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}",
+ "openlark-application_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}",
+ "openlark-auth_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"features\":[\"serde\",\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"features\":[\"html_reports\"],\"name\":\"criterion\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12.7\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.18\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"optional\":true,\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\",\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"advanced-cache\":[\"cache\",\"encryption\"],\"cache\":[\"token-management\"],\"default\":[\"token-management\",\"cache\",\"oauth\"],\"encryption\":[\"ring\",\"sha2\",\"hmac\",\"pbkdf2\"],\"monitoring\":[],\"oauth\":[\"reqwest\",\"url\"],\"token-management\":[]}}",
+ "openlark-cardkit_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"kind\":\"dev\",\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"v1\"],\"full\":[\"v1\"],\"v1\":[]}}",
+ "openlark-client_0.15.0-rc.2": "{\"dependencies\":[{\"features\":[\"serde\",\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"lark-websocket-protobuf\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-ai\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-auth\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-cardkit\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-communication\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-docs\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-hr\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-meeting\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-security\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"rustls-tls-native-roots\"],\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"optional\":true,\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\",\"v4\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"ai\":[\"dep:openlark-ai\"],\"auth\":[\"dep:openlark-auth\"],\"cardkit\":[\"auth\",\"dep:openlark-cardkit\"],\"communication\":[\"auth\",\"dep:openlark-communication\"],\"core-layer\":[\"communication\",\"docs\",\"security\"],\"default\":[\"auth\",\"communication\"],\"docs\":[\"auth\",\"dep:openlark-docs\"],\"hr\":[\"dep:openlark-hr\"],\"meeting\":[\"auth\",\"dep:openlark-meeting\"],\"p0-services\":[\"communication\",\"docs\",\"security\"],\"security\":[\"auth\",\"dep:openlark-security\"],\"websocket\":[\"tokio-tungstenite\",\"futures-util\",\"lark-websocket-protobuf\",\"url\",\"prost\",\"reqwest\",\"log\"]}}",
+ "openlark-communication_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"aily\":[],\"contact\":[],\"default\":[\"im\",\"contact\",\"moments\"],\"full\":[\"im\",\"contact\",\"moments\",\"aily\"],\"im\":[],\"moments\":[]}}",
+ "openlark-core_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"lark-websocket-protobuf\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"num_cpus\",\"req\":\"^1.16\"},{\"name\":\"openlark-protocol\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"opentelemetry\",\"optional\":true,\"req\":\"^0.24\"},{\"name\":\"opentelemetry-otlp\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"rt-tokio\"],\"name\":\"opentelemetry_sdk\",\"optional\":true,\"req\":\"^0.24\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"quick_cache\",\"req\":\"^0.6.3\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_with\",\"req\":\"^3\"},{\"name\":\"sha2\",\"req\":\"^0.10.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"features\":[\"rustls-tls-native-roots\"],\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"tracing-opentelemetry\",\"optional\":true,\"req\":\"^0.25\"},{\"features\":[\"env-filter\",\"json\"],\"name\":\"tracing-subscriber\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"env-filter\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"testing\"],\"otel\":[\"tracing-init\",\"opentelemetry\",\"opentelemetry_sdk\",\"opentelemetry-otlp\",\"tracing-opentelemetry\"],\"testing\":[\"tracing-init\"],\"tracing-init\":[\"tracing-subscriber\"],\"websocket\":[\"tokio-tungstenite\",\"prost\",\"openlark-protocol\",\"lark-websocket-protobuf\"]}}",
+ "openlark-docs_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-cloud-docs\":[\"full\"],\"baike\":[],\"base\":[\"core\"],\"bitable\":[\"core\"],\"ccm\":[\"ccm-core\",\"ccm-doc\",\"ccm-docx\",\"ccm-drive\",\"ccm-sheets\",\"ccm-wiki\"],\"ccm-core\":[],\"ccm-doc\":[\"ccm-core\"],\"ccm-docx\":[\"ccm-core\"],\"ccm-drive\":[\"ccm-core\"],\"ccm-sheets\":[\"ccm-sheets-v3\"],\"ccm-sheets-v3\":[\"ccm-core\"],\"ccm-wiki\":[\"ccm-core\"],\"cloud-docs\":[\"ccm\",\"bitable\",\"base\"],\"core\":[],\"default\":[],\"docs\":[\"ccm-doc\"],\"docx\":[\"ccm-docx\"],\"full\":[\"ccm\",\"bitable\",\"base\",\"baike\",\"minutes\",\"v3\"],\"lingo\":[],\"minutes\":[\"core\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"wiki\":[\"ccm-wiki\"]}}",
+ "openlark-helpdesk_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}",
+ "openlark-hr_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"attendance\":[],\"compensation\":[],\"corehr\":[],\"default\":[\"attendance\",\"corehr\",\"compensation\",\"payroll\",\"performance\",\"okr\",\"hire\",\"ehr\"],\"ehr\":[],\"hire\":[],\"hr-full\":[\"attendance\",\"corehr\",\"compensation\",\"payroll\",\"performance\",\"okr\",\"hire\",\"ehr\"],\"okr\":[],\"payroll\":[],\"performance\":[]}}",
+ "openlark-mail_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}",
+ "openlark-meeting_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1\"}],\"features\":{\"calendar\":[\"calendar-v4\"],\"calendar-v4\":[],\"default\":[\"vc\",\"calendar\"],\"full\":[\"vc\",\"calendar\",\"meeting-room\"],\"meeting-room\":[\"meeting-room-v1\"],\"meeting-room-v1\":[],\"vc\":[\"vc-v1\"],\"vc-v1\":[]}}",
+ "openlark-platform_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"admin\":[\"admin-core\",\"core\"],\"admin-core\":[],\"all-platform\":[\"full\"],\"app-engine\":[\"app-engine-core\",\"core\"],\"app-engine-core\":[],\"core\":[],\"default\":[\"app-engine\",\"directory\",\"admin\",\"mdm\",\"tenant\",\"trust_party\"],\"directory\":[\"directory-core\",\"core\"],\"directory-core\":[],\"full\":[\"app-engine\",\"directory\",\"admin\",\"mdm\",\"tenant\",\"trust_party\",\"v4\"],\"mdm\":[\"mdm-core\",\"core\"],\"mdm-core\":[],\"platform\":[\"app-engine\",\"directory\",\"admin\"],\"tenant\":[\"tenant-core\",\"core\"],\"tenant-core\":[],\"trust_party\":[\"trust_party-core\",\"core\"],\"trust_party-core\":[],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}",
+ "openlark-protocol_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.6.0\"},{\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"kind\":\"build\",\"name\":\"prost-build\",\"req\":\"^0.12.6\"}],\"features\":{}}",
+ "openlark-security_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"name\":\"sha2\",\"req\":\"^0.10.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"acs\":[\"auth\"],\"audit\":[\"core\"],\"auth\":[\"core\"],\"compliance\":[\"auth\"],\"core\":[],\"default\":[\"auth\",\"acs\"],\"full\":[\"auth\",\"acs\",\"audit\",\"token\",\"compliance\",\"v3\"],\"security\":[\"full\"],\"token\":[\"auth\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"]}}",
+ "openlark-user_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-user\":[\"full\"],\"core\":[],\"default\":[\"settings\",\"preferences\"],\"full\":[\"settings\",\"preferences\",\"v4\"],\"preferences\":[\"preferences-core\",\"core\"],\"preferences-core\":[],\"settings\":[\"settings-core\",\"core\"],\"settings-core\":[],\"user\":[\"settings\",\"preferences\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}",
+ "openlark-webhook_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"card\":[],\"default\":[\"robot\"],\"robot\":[],\"signature\":[\"hmac\",\"sha2\",\"base64\"]}}",
+ "openlark-workflow_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"board\":[],\"default\":[\"v1\",\"v2\",\"async\",\"board\"],\"full\":[\"v1\",\"v2\",\"async\",\"board\"],\"v1\":[],\"v2\":[]}}",
+ "openlark_0.15.0-rc.1": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"colored\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-ai\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-analytics\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-application\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-auth\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-cardkit\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-client\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-communication\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-docs\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-helpdesk\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-hr\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-mail\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-meeting\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-platform\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-protocol\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-security\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-user\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-webhook\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-workflow\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"kind\":\"dev\",\"name\":\"test-log\",\"req\":\"^0.2\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"ai\":[\"client\",\"openlark-ai\"],\"analytics\":[\"client\",\"openlark-analytics\"],\"application\":[\"client\",\"openlark-application\"],\"auth\":[\"client\",\"openlark-auth\"],\"base\":[\"client\",\"openlark-docs\"],\"bitable\":[\"client\",\"openlark-docs\"],\"cardkit\":[\"client\",\"openlark-cardkit\"],\"client\":[\"openlark-client\"],\"communication\":[\"client\",\"openlark-communication\"],\"core-services\":[\"auth\",\"communication\",\"docs\",\"workflow\"],\"default\":[\"core-services\"],\"dev-tools\":[],\"docs\":[\"client\",\"openlark-docs\"],\"helpdesk\":[\"client\",\"openlark-helpdesk\"],\"hr\":[\"client\",\"openlark-hr\"],\"mail\":[\"client\",\"openlark-mail\"],\"meeting\":[\"client\",\"openlark-meeting\"],\"platform\":[\"client\",\"openlark-platform\"],\"protocol\":[\"openlark-protocol\"],\"security\":[\"client\",\"openlark-security\"],\"user\":[\"client\",\"openlark-user\"],\"webhook\":[\"client\",\"openlark-webhook\"],\"websocket\":[\"protocol\",\"openlark-client/websocket\"],\"workflow\":[\"client\",\"openlark-workflow\"]}}",
"openssl-macros_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}",
"openssl-probe_0.1.6": "{\"dependencies\":[],\"features\":{}}",
"openssl-probe_0.2.1": "{\"dependencies\":[],\"features\":{}}",
@@ -1169,7 +1190,13 @@
"proc-macro2_1.0.106": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"proc-macro\"],\"nightly\":[],\"proc-macro\":[],\"span-locations\":[]}}",
"process-wrap_9.0.1": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"default_features\":false,\"features\":[\"fs\",\"poll\",\"signal\"],\"name\":\"nix\",\"optional\":true,\"req\":\"^0.30.1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"remoteprocess\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.20.0\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38.2\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38.2\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"name\":\"windows\",\"optional\":true,\"req\":\"^0.62.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"creation-flags\":[\"dep:windows\",\"windows/Win32_System_Threading\"],\"default\":[\"creation-flags\",\"job-object\",\"kill-on-drop\",\"process-group\",\"process-session\",\"tracing\"],\"job-object\":[\"dep:windows\",\"windows/Win32_Security\",\"windows/Win32_System_Diagnostics_ToolHelp\",\"windows/Win32_System_IO\",\"windows/Win32_System_JobObjects\",\"windows/Win32_System_Threading\"],\"kill-on-drop\":[],\"process-group\":[],\"process-session\":[\"process-group\"],\"reset-sigmask\":[],\"std\":[\"dep:nix\"],\"tokio1\":[\"dep:nix\",\"dep:futures\",\"dep:tokio\"],\"tracing\":[\"dep:tracing\"]}}",
"proptest_1.9.0": "{\"dependencies\":[{\"name\":\"bit-set\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bitflags\",\"req\":\"^2.9\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.15\"},{\"name\":\"proptest-macro\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"req\":\"^0.9\"},{\"name\":\"rand_xorshift\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rusty-fork\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"=1.0.112\"},{\"name\":\"unarray\",\"req\":\"^0.1.4\"},{\"name\":\"x86\",\"optional\":true,\"req\":\"^0.52.0\"}],\"features\":{\"alloc\":[],\"atomic64bit\":[],\"attr-macro\":[\"proptest-macro\"],\"bit-set\":[\"dep:bit-set\",\"dep:bit-vec\"],\"default\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"default-code-coverage\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"fork\":[\"std\",\"rusty-fork\",\"tempfile\"],\"handle-panics\":[\"std\"],\"hardware-rng\":[\"x86\"],\"no_std\":[\"num-traits/libm\"],\"std\":[\"rand/std\",\"rand/os_rng\",\"regex-syntax\",\"num-traits/std\"],\"timeout\":[\"fork\",\"rusty-fork/timeout\"],\"unstable\":[]}}",
+ "prost-build_0.12.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.12\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"name\":\"once_cell\",\"req\":\"^1.17.1\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.12.6\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.12.6\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.9.1\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^10.0.1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-bool\"],\"name\":\"regex\",\"req\":\"^1.8.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"cleanup-markdown\":[\"dep:pulldown-cmark\",\"dep:pulldown-cmark-to-cmark\"],\"default\":[\"format\"],\"format\":[\"dep:prettyplease\",\"dep:syn\"]}}",
+ "prost-derive_0.12.6": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.12\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}",
+ "prost-derive_0.13.5": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.14\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}",
"prost-derive_0.14.3": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.14\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}",
+ "prost-types_0.12.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"prost-derive\"],\"name\":\"prost\",\"req\":\"^0.12.6\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"prost/std\"]}}",
+ "prost_0.12.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.12.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"prost-derive\":[\"derive\"],\"std\":[]}}",
+ "prost_0.13.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.13.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"prost-derive\":[\"derive\"],\"std\":[]}}",
"prost_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"std\":[]}}",
"psl-types_2.0.11": "{\"dependencies\":[],\"features\":{}}",
"psl_2.1.184": "{\"dependencies\":[{\"name\":\"psl-types\",\"req\":\"^2.0.11\"},{\"kind\":\"dev\",\"name\":\"rspec\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"helpers\"],\"helpers\":[]}}",
@@ -1178,6 +1205,7 @@
"pxfm_0.1.27": "{\"dependencies\":[{\"name\":\"num-traits\",\"req\":\"^0.2.3\"}],\"features\":{}}",
"quick-error_2.0.1": "{\"dependencies\":[],\"features\":{}}",
"quick-xml_0.38.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.8\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.139\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}",
+ "quick_cache_0.6.21": "{\"dependencies\":[{\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"crossbeam-utils\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"name\":\"shuttle\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"default\":[\"ahash\",\"parking_lot\"],\"sharded-lock\":[\"dep:crossbeam-utils\"],\"shuttle\":[\"dep:shuttle\"],\"stats\":[]}}",
"quinn-proto_0.11.13": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"fastbloom\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"lru-slab\",\"req\":\"^0.1.2\"},{\"name\":\"qlog\",\"optional\":true,\"req\":\"^0.15.2\"},{\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"features\":[\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"features\":[\"web\"],\"name\":\"rustls-pki-types\",\"req\":\"^1.7\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"slab\",\"req\":\"^0.4.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"alloc\",\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs?/aws-lc-sys\",\"aws-lc-rs?/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"aws-lc-rs\",\"aws-lc-rs?/fips\"],\"bloom\":[\"dep:fastbloom\"],\"default\":[\"rustls-ring\",\"log\",\"bloom\"],\"log\":[\"tracing/log\"],\"platform-verifier\":[\"dep:rustls-platform-verifier\"],\"qlog\":[\"dep:qlog\"],\"ring\":[\"dep:ring\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"rustls?/aws-lc-rs\",\"aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"rustls-aws-lc-rs\",\"aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"ring\"]}}",
"quinn-udp_0.5.14": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}",
"quinn_0.11.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.22\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.11\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"crc\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"directories-next\",\"req\":\"^2\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.19\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"proto\",\"package\":\"quinn-proto\",\"req\":\"^0.11.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"smol\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"std-future\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"tracing\"],\"name\":\"udp\",\"package\":\"quinn-udp\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"proto/aws-lc-rs\"],\"aws-lc-rs-fips\":[\"proto/aws-lc-rs-fips\"],\"bloom\":[\"proto/bloom\"],\"default\":[\"log\",\"platform-verifier\",\"runtime-tokio\",\"rustls-ring\",\"bloom\"],\"lock_tracking\":[],\"log\":[\"tracing/log\",\"proto/log\",\"udp/log\"],\"platform-verifier\":[\"proto/platform-verifier\"],\"qlog\":[\"proto/qlog\"],\"ring\":[\"proto/ring\"],\"runtime-async-std\":[\"async-io\",\"async-std\"],\"runtime-smol\":[\"async-io\",\"smol\"],\"runtime-tokio\":[\"tokio/time\",\"tokio/rt\",\"tokio/net\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"aws-lc-rs\",\"proto/rustls-aws-lc-rs\",\"proto/aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"dep:rustls\",\"aws-lc-rs-fips\",\"proto/rustls-aws-lc-rs-fips\",\"proto/aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"ring\",\"proto/rustls-ring\",\"proto/ring\"]}}",
@@ -1249,7 +1277,9 @@
"rustix_0.38.44": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.161\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.5.2\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_NetworkManagement_IpHelper\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"procfs\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"cc\":[],\"default\":[\"std\",\"use-libc-auxv\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"linux-raw-sys/io_uring\"],\"libc-extra-traits\":[\"libc?/extra_traits\"],\"linux_4_11\":[],\"linux_latest\":[\"linux_4_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[\"fs\"],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"procfs\":[\"once_cell\",\"itoa\",\"fs\"],\"pty\":[\"itoa\",\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"compiler_builtins\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\",\"compiler_builtins?/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\",\"libc-extra-traits\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\",\"libc-extra-traits\"],\"use-libc-auxv\":[]}}",
"rustix_1.1.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}",
"rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}",
+ "rustls-native-certs_0.7.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.1.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.16\"}],\"features\":{}}",
"rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}",
+ "rustls-pemfile_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.9\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"pki-types/std\"]}}",
"rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}",
"rustls-webpki_0.103.10": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}",
"rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}",
@@ -1407,6 +1437,7 @@
"tokio-rustls_0.26.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"brotli\":[\"rustls/brotli\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"],\"zlib\":[\"rustls/zlib\"]}}",
"tokio-stream_0.1.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}",
"tokio-test_0.4.5": "{\"dependencies\":[{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"features\":[\"rt\",\"sync\",\"time\",\"test-util\"],\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"}],\"features\":{}}",
+ "tokio-tungstenite_0.23.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.23.0\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]}}",
"tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}",
"tokio_1.49.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}",
"toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}",
@@ -1442,6 +1473,7 @@
"try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}",
"ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}",
"ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}",
+ "tungstenite_0.23.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.3.2\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\"},{\"name\":\"thiserror\",\"req\":\"^1.0.23\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"utf-8\",\"req\":\"^0.7.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]}}",
"two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}",
"type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}",
"typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}",
diff --git a/README.md b/README.md
index 1e44875f2..318f0a0eb 100644
--- a/README.md
+++ b/README.md
@@ -1,60 +1,196 @@
-npm i -g @openai/codex or brew install --cask codex
-Codex CLI is a coding agent from OpenAI that runs locally on your computer.
-
-
-
-
-If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE.
-If you want the desktop app experience, run codex app or visit the Codex App page .
-If you are looking for the cloud-based agent from OpenAI, Codex Web , go to chatgpt.com/codex .
-
----
+
+
+# Codex Enhanced
+
+> The Codex distribution built for real 24/7 use.
+
+[中文版本](./README.zh-CN.md) · [Website](https://codex-enhanced.com) · [Workflows](./docs/workflows.md) · [Slash Commands](./docs/slash_commands.md) · [Structured Input UI](./docs/tui-request-user-input.md)
+
+
+
+`codex-enhanced` is a Codex distribution built on top of the OpenAI Codex CLI Rust stack. It is focused less on prompt theater and more on turning Codex into an operator surface that can stay online across accounts, sessions, workflows, and external message channels.
+
+If you need a terminal chatbot, the base Codex experience is already strong. This distribution is for the next step: keeping Codex running as a controllable workspace system instead of a single ephemeral chat loop.
+
+## Why codex-enhanced
+
+Most AI CLI projects compete on model access and UI polish.
+
+`codex-enhanced` moves the investment somewhere else:
+
+- multi-subscription account management instead of manual env switching
+- multi-profile routing and fallback instead of a single fragile default
+- resumable sessions instead of repeated context rebuilds
+- workflow triggers and background jobs instead of one-turn-at-a-time operation
+- Feishu bridge entrypoints instead of terminal-only interaction
+- lower-noise operator UX instead of more surface-level prompt ceremony
+
+That is the core idea: make Codex feel less like a terminal chatbot and more like a persistent control surface.
+
+## What You Can Do
+
+| Capability | Entry point | What it enables |
+| --- | --- | --- |
+| Multi-profile routing | `/profile` | Switch named profiles at runtime, manage fallback routes, and recover from rate limits or auth failures without rewriting local environment state. |
+| Workflow orchestration | `/workflow` | Manage `.codex/workflows/*.yaml`, run jobs manually, and attach triggers such as `before_turn`, `after_turn`, `interval`, `cron`, and `file_watch`. |
+| Session insight report | `/insight` | Scan local Codex sessions and generate an offline HTML report under `~/.codex/reports/` for rollout analysis and drill-down. |
+| Session continuity | `/resume` | Reconnect to saved work instead of reconstructing long-running context from scratch. |
+| External message bridge | `/clawbot` | Bind workspace-local Feishu sessions to Codex threads, capture unread messages, and forward final replies back out. |
+| UI and alignment control | `/settings`, `question`, keyboard chords | Reduce noise, collect structured answers in the TUI, and keep operator interactions explicit. |
## Quickstart
-### Installing and running Codex CLI
+Install from PyPI:
-Install globally with your preferred package manager:
+```bash
+pip3 install -U codex-enhanced
+codex-enhanced
+```
+
+Run from source in this repository:
-```shell
-# Install using npm
-npm install -g @openai/codex
+```bash
+just codex
```
-```shell
-# Install using Homebrew
-brew install --cask codex
+`just codex` runs `cargo run --bin codex -- ...` from the `codex-rs` workspace, which is the fastest way to inspect or develop the Rust TUI locally.
+
+## Typical Operator Flows
+
+### 1. Route traffic across multiple accounts and profiles
+
+`/profile` opens a dedicated management panel for:
+
+- named profiles
+- runtime switching
+- fallback routes
+- switching policies for rate limits, auth failures, and service overload
+
+This is the difference between "change a key and restart the tool" and "keep the operator surface online."
+
+### 2. Turn conversations into jobs
+
+`/workflow` manages workflow definitions directly from `.codex/workflows/*.yaml`.
+
+Supported trigger families include:
+
+- `before_turn`
+- `after_turn`
+- `manual`
+- `file_watch`
+- `idle`
+- `interval`
+- `cron`
+
+That lets Codex participate in repeatable automation loops instead of only answering the current prompt.
+
+Minimal example:
+
+```yaml
+name: director
+
+triggers:
+ - id: pulse
+ type: interval
+ every: 30m
+ enabled: true
+ jobs: [notify]
+
+jobs:
+ notify:
+ enabled: true
+ context: ephemeral
+ response: assistant
+ steps:
+ - prompt: |
+ Send a concise update on the current workspace state.
```
-Then simply run `codex` to get started.
+Documentation:
+
+- [`docs/workflows.md`](./docs/workflows.md)
+
+### 3. Resume work instead of restarting it
+
+`/resume` and the underlying thread/session plumbing let you reconnect to saved work instead of paying the cost of rebuilding context every time a session is interrupted.
+
+This matters once Codex is doing real work over hours or days rather than a single short exchange.
+
+### 4. Bring Feishu into the same loop
+
+`/clawbot` connects workspace-local Feishu sessions, thread binding, unread message queues, and reply forwarding into the same runtime.
+
+In practice, that means:
+
+- a Feishu chat can be bound to the current Codex thread
+- inbound messages can enter the active workspace loop
+- final replies can be sent back to Feishu
+- session and binding state stays local to the workspace runtime
+
+## What Ships Today
+
+These capabilities are already implemented in the repository:
+
+- multi-subscription account management and runtime account display
+- multi-profile API management and `/profile` route switching
+- `/workflow` task orchestration
+- `/resume` for saved sessions
+- `/settings` for UI information control
+- `/clawbot` for Feishu send and receive flows
+- stronger `question`-based alignment interactions
+- keyboard chord support
+- PyPI packaging and release flow for `codex-enhanced`
+
+## Inspectable by Design
+
+This project is intentionally built so the important runtime state stays visible:
+
+- profile routing state lives in `accounts/profile-router.json`
+- workflows live in `.codex/workflows/*.yaml`
+- clawbot state is stored under `.codex/clawbot/`
+- structured operator answers are collected through the TUI `question` flow instead of being guessed from ambiguous free text
+
+This distribution is opinionated, but it is not trying to hide the system from the operator.
+
+## Repository Map
+
+If you want to inspect or extend the project, start here:
+
+- [`codex-rs/`](./codex-rs) contains the Rust workspace, including the CLI, TUI, workflow support, app-server pieces, and clawbot integration
+- [`sdk/python-runtime-enhanced/`](./sdk/python-runtime-enhanced) contains the Python wheel packaging for `codex-enhanced`
+- [`docs/workflows.md`](./docs/workflows.md) explains workflow files, triggers, and job management
+- [`docs/slash_commands.md`](./docs/slash_commands.md) documents TUI slash commands including `/insight`
+- [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) explains the structured input overlay used for `question`
+
+## Capability Boundaries
+
+### What it is built to solve
-
-You can also go to the latest GitHub Release and download the appropriate binary for your platform.
+This distribution is strong at connecting the agent to real operating workflows.
-Each GitHub Release contains many executables, but in practice, you likely want one of these:
+Current strengths:
-- macOS
- - Apple Silicon/arm64: `codex-aarch64-apple-darwin.tar.gz`
- - x86_64 (older Mac hardware): `codex-x86_64-apple-darwin.tar.gz`
-- Linux
- - x86_64: `codex-x86_64-unknown-linux-musl.tar.gz`
- - arm64: `codex-aarch64-unknown-linux-musl.tar.gz`
+- multi-account and multi-profile routing
+- long-session recovery and continuity
+- workspace-local workflow orchestration
+- Feishu clawbot integration
+- local TUI information shaping and visibility control
+- stronger alignment flows for human-in-the-loop operation
-Each archive contains a single entry with the platform baked into the name (e.g., `codex-x86_64-unknown-linux-musl`), so you likely want to rename it to `codex` after extracting it.
+### What it is not trying to be
-
+- a replacement for every official hosted or distributed Codex surface
+- a general-purpose IM automation hub beyond the current Feishu focus
+- a zero-configuration black box for business workflow automation
-### Using Codex with your ChatGPT plan
+## Project Attribution
-Run `codex` and select **Sign in with ChatGPT**. We recommend signing into your ChatGPT account to use Codex as part of your Plus, Pro, Team, Edu, or Enterprise plan. [Learn more about what's included in your ChatGPT plan](https://help.openai.com/en/articles/11369540-codex-in-chatgpt).
+This project builds on the OpenAI Codex CLI Rust, TUI, and app-server foundation, then pushes harder on the parts that matter in sustained use: account operations, session continuity, workflows, Feishu entrypoints, lower-noise UI, and operator ergonomics.
-You can also use Codex with an API key, but this requires [additional setup](https://developers.openai.com/codex/auth#sign-in-with-an-api-key).
+If you only need a Codex that chats in a terminal, the official distribution is already enough.
-## Docs
+If you need a Codex that can stay online across accounts, inputs, tasks, and long-running sessions, that is the point of this distribution.
-- [**Codex Documentation**](https://developers.openai.com/codex)
-- [**Contributing**](./docs/contributing.md)
-- [**Installing & building**](./docs/install.md)
-- [**Open source fund**](./docs/open-source-fund.md)
+## License
-This repository is licensed under the [Apache-2.0 License](LICENSE).
+Apache-2.0
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 000000000..4d5cc61be
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,196 @@
+
+
+# Codex Enhanced
+
+> 真正 24/7 使用的 Codex 发行版。
+
+[English](./README.md) · [Website](https://codex-enhanced.com) · [工作流文档](./docs/workflows.md) · [Slash Command 文档](./docs/slash_commands.md) · [结构化输入 UI](./docs/tui-request-user-input.md)
+
+
+
+`codex-enhanced` 是一个基于 OpenAI Codex CLI Rust 技术栈继续演进的 Codex 发行版。它的重点不是再包一层 prompt,而是把 Codex 变成一个可以跨账户、跨会话、跨工作流、跨外部消息入口持续在线的 operator surface。
+
+如果你只需要一个终端聊天工具,基础 Codex 体验已经足够强。这个发行版要解决的是下一层问题:让 Codex 不只是一次性的对话循环,而是一个可持续运转的工作台。
+
+## 为什么是 codex-enhanced
+
+大多数 AI CLI 项目主要在卷模型接入和 UI 打磨。
+
+`codex-enhanced` 把工程投入放在另一侧:
+
+- 多 subscription 账户管理,而不是手动切环境变量
+- 多 profile 路由和 fallback,而不是只有一个脆弱默认值
+- 会话续接,而不是反复重建上下文
+- 工作流触发器和后台任务,而不是永远一问一答
+- 飞书桥接入口,而不是只接受终端输入
+- 更低噪音的 operator UX,而不是继续堆 prompt 仪式感
+
+核心目标只有一个:让 Codex 更像持续在线的控制台,而不是终端里的聊天框。
+
+## 你可以做什么
+
+| 能力 | 入口 | 能解决什么 |
+| --- | --- | --- |
+| 多 profile 路由 | `/profile` | 在运行时切换命名 profile、管理 fallback route,并在限流或鉴权失败时继续运转,而不是改完配置再重启。 |
+| 工作流编排 | `/workflow` | 直接管理 `.codex/workflows/*.yaml`,手动运行 job,或者挂接 `before_turn`、`after_turn`、`interval`、`cron`、`file_watch` 等触发器。 |
+| 会话洞察报告 | `/insight` | 扫描本地 Codex session,并在 `~/.codex/reports/` 下生成离线 HTML 分析报告,便于回看 rollout 和逐层钻取。 |
+| 会话连续性 | `/resume` | 把保存过的工作续上,而不是每次从零重建长上下文。 |
+| 外部消息桥接 | `/clawbot` | 把 workspace-local 的飞书会话绑定到 Codex thread,接收未读消息并把最终回复发回外部。 |
+| UI 与对齐控制 | `/settings`、`question`、键盘 chord | 降低界面噪音,在 TUI 中收集结构化答案,并让人工参与点更明确。 |
+
+## Quickstart
+
+通过 PyPI 安装:
+
+```bash
+pip3 install -U codex-enhanced
+codex-enhanced
+```
+
+在当前仓库里直接从源码运行:
+
+```bash
+just codex
+```
+
+`just codex` 实际执行的是 `codex-rs` 工作区下的 `cargo run --bin codex -- ...`,这是本地调试和验证 Rust TUI 的最快路径。
+
+## 典型使用流
+
+### 1. 在多个账户和 profile 之间路由流量
+
+`/profile` 会打开独立管理面板,支持:
+
+- 命名 profile
+- 当前 runtime 切换
+- fallback route
+- 在限流、鉴权失败、服务过载时按策略切换 profile
+
+这和“改个 key 然后重开工具”是两种完全不同的操作体验。
+
+### 2. 把对话变成可编排 job
+
+`/workflow` 直接管理 `.codex/workflows/*.yaml` 中的工作流定义。
+
+目前支持的触发类型包括:
+
+- `before_turn`
+- `after_turn`
+- `manual`
+- `file_watch`
+- `idle`
+- `interval`
+- `cron`
+
+这意味着 Codex 不只是响应当前输入,还可以进入可重复执行的自动化闭环。
+
+最小示例:
+
+```yaml
+name: director
+
+triggers:
+ - id: pulse
+ type: interval
+ every: 30m
+ enabled: true
+ jobs: [notify]
+
+jobs:
+ notify:
+ enabled: true
+ context: ephemeral
+ response: assistant
+ steps:
+ - prompt: |
+ Send a concise update on the current workspace state.
+```
+
+相关文档:
+
+- [`docs/workflows.md`](./docs/workflows.md)
+
+### 3. 续上工作,而不是反复重开
+
+`/resume` 和底层的 thread/session 基础设施允许你把保存过的工作重新接起来,而不是每次都付出重建上下文的成本。
+
+当 Codex 开始承担按小时或按天推进的任务时,这一点会非常重要。
+
+### 4. 把飞书接进同一个闭环
+
+`/clawbot` 把 workspace-local 的飞书会话、线程绑定、未读消息队列和回复回传放进同一个运行时。
+
+具体来说,它支持:
+
+- 把飞书会话绑定到当前 Codex thread
+- 让外部消息进入当前 workspace 的执行闭环
+- 把最终回复发回飞书
+- 让 session 和 binding 状态保持在 workspace 本地
+
+## 当前已经落地的能力
+
+下面这些能力都已经在仓库中实现:
+
+- 多 subscription 账户管理和 runtime account 展示
+- 多 profile API 管理和 `/profile` 路由切换
+- `/workflow` 任务编排
+- `/resume` 恢复已保存会话
+- `/settings` 控制 UI 展示信息
+- `/clawbot` 对接飞书收发消息
+- 更强的 `question` 式对齐交互
+- 键盘 chord 支持
+- `codex-enhanced` 的 PyPI 打包与发布流程
+
+## 可观测设计
+
+这个项目有意把关键状态保持为可检查、可定位的本地文件:
+
+- profile 路由状态保存在 `accounts/profile-router.json`
+- workflow 定义直接保存在 `.codex/workflows/*.yaml`
+- clawbot 相关状态保存在 `.codex/clawbot/`
+- operator 的结构化回答通过 TUI 中的 `question` 流收集,而不是靠自由文本猜测
+
+它是有观点的,但不是黑盒。
+
+## 仓库导览
+
+如果你要继续查看实现或扩展能力,可以从这些位置开始:
+
+- [`codex-rs/`](./codex-rs) 是 Rust 工作区,包含 CLI、TUI、workflow、app-server 和 clawbot 集成
+- [`sdk/python-runtime-enhanced/`](./sdk/python-runtime-enhanced) 是 `codex-enhanced` 的 Python wheel 打包目录
+- [`docs/workflows.md`](./docs/workflows.md) 说明 workflow 文件、trigger 和 job 管理方式
+- [`docs/slash_commands.md`](./docs/slash_commands.md) 说明 TUI slash commands,包括 `/insight`
+- [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) 说明 `question` 使用的结构化输入浮层
+
+## 能力边界
+
+### 它主要解决什么
+
+这个发行版的强项,是把 agent 接进真实可操作的工作流。
+
+当前重点能力:
+
+- 多账户和多 profile 路由
+- 长会话恢复与连续性
+- workspace-local workflow orchestration
+- Feishu clawbot 集成
+- 本地 TUI 信息展示裁剪和可见性控制
+- 更强的人在环对齐交互
+
+### 它不打算解决什么
+
+- 替代官方原版的全部托管和分发形态
+- 在飞书之外直接变成通用 IM 自动化中台
+- 把业务流程自动化做成零配置黑盒
+
+## 项目说明
+
+这个项目不是从零开始。它建立在 OpenAI Codex CLI 的 Rust、TUI 和 app-server 基础之上,然后把精力进一步放到长期使用更痛的部分:账户运营、会话连续性、workflow、飞书入口、更低噪音的 UI 和 operator ergonomics。
+
+如果你只需要一个在终端里聊天的 Codex,官方原版已经够用。
+
+如果你需要一个能长期在线,能跨账户、跨入口、跨任务持续运转的 Codex,这就是这个发行版存在的理由。
+
+## License
+
+Apache-2.0
diff --git a/codex-rs/.gitignore b/codex-rs/.gitignore
index e31566047..b8563bddf 100644
--- a/codex-rs/.gitignore
+++ b/codex-rs/.gitignore
@@ -6,3 +6,5 @@
# Value of CARGO_TARGET_DIR when using .devcontainer/devcontainer.json.
/target-arm64/
+
+.codex/
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 5cc4fe2ee..4d67a13ec 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -448,7 +448,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -800,7 +800,7 @@ dependencies = [
"sha1",
"sync_wrapper",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"tower",
"tower-layer",
"tower-service",
@@ -1385,10 +1385,10 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-test",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"tokio-util",
"tracing",
- "tungstenite",
+ "tungstenite 0.27.0",
"url",
"wiremock",
]
@@ -1452,7 +1452,7 @@ dependencies = [
"tempfile",
"time",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"tracing",
@@ -1477,7 +1477,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"toml 0.9.11+spec-1.1.0",
"tracing",
"url",
@@ -1527,7 +1527,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
- "tungstenite",
+ "tungstenite 0.27.0",
"url",
"uuid",
]
@@ -1616,6 +1616,20 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "codex-clawbot"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "openlark",
+ "pretty_assertions",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "tokio",
+ "toml 0.9.11+spec-1.1.0",
+]
+
[[package]]
name = "codex-cli"
version = "0.0.0"
@@ -1684,7 +1698,7 @@ dependencies = [
"rand 0.9.2",
"reqwest",
"rustls",
- "rustls-native-certs",
+ "rustls-native-certs 0.8.3",
"rustls-pki-types",
"serde",
"serde_json",
@@ -1935,7 +1949,7 @@ dependencies = [
"test-log",
"thiserror 2.0.18",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"toml_edit 0.24.0+spec-1.1.0",
@@ -2059,7 +2073,7 @@ dependencies = [
"test-case",
"thiserror 2.0.18",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"tracing",
]
@@ -2469,7 +2483,7 @@ dependencies = [
"strum_macros 0.28.0",
"thiserror 2.0.18",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
@@ -2781,6 +2795,7 @@ dependencies = [
"codex-app-server-protocol",
"codex-arg0",
"codex-chatgpt",
+ "codex-clawbot",
"codex-cli",
"codex-cloud-requirements",
"codex-config",
@@ -2807,6 +2822,7 @@ dependencies = [
"codex-utils-fuzzy-match",
"codex-utils-oss",
"codex-utils-pty",
+ "codex-utils-rustls-provider",
"codex-utils-sandbox-summary",
"codex-utils-sleep-inhibitor",
"codex-utils-string",
@@ -2818,11 +2834,14 @@ dependencies = [
"diffy",
"dirs",
"dunce",
+ "humantime",
"image",
+ "indexmap 2.13.0",
"insta",
"itertools 0.14.0",
"lazy_static",
"libc",
+ "notify",
"pathdiff",
"pretty_assertions",
"pulldown-cmark",
@@ -2834,6 +2853,7 @@ dependencies = [
"rmcp",
"serde",
"serde_json",
+ "serde_yaml",
"serial_test",
"shlex",
"strum 0.27.2",
@@ -3317,7 +3337,7 @@ dependencies = [
"shlex",
"tempfile",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
@@ -3956,7 +3976,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4201,7 +4221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5035,6 +5055,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+[[package]]
+name = "humantime"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
+
[[package]]
name = "hyper"
version = "1.8.1"
@@ -5068,7 +5094,7 @@ dependencies = [
"hyper",
"hyper-util",
"rustls",
- "rustls-native-certs",
+ "rustls-native-certs 0.8.3",
"rustls-pki-types",
"tokio",
"tokio-rustls",
@@ -5122,7 +5148,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.5.10",
+ "socket2 0.6.2",
"system-configuration",
"tokio",
"tower-service",
@@ -5413,9 +5439,9 @@ dependencies = [
[[package]]
name = "image"
-version = "0.25.9"
+version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
@@ -5426,8 +5452,8 @@ dependencies = [
"num-traits",
"png",
"tiff",
- "zune-core 0.5.1",
- "zune-jpeg 0.5.12",
+ "zune-core",
+ "zune-jpeg",
]
[[package]]
@@ -5628,7 +5654,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5849,6 +5875,17 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
+[[package]]
+name = "lark-websocket-protobuf"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79d4b1f8c37d37efd353c1b116df0f98ff21c8bcb216f67ab026dd2fd2b9ab81"
+dependencies = [
+ "bytes",
+ "prost 0.13.5",
+ "prost-build",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -6233,9 +6270,9 @@ dependencies = [
[[package]]
name = "moxcms"
-version = "0.7.11"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
@@ -6406,7 +6443,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6582,7 +6619,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
- "base64 0.21.7",
+ "base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.4.0",
@@ -6852,6 +6889,113 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+[[package]]
+name = "openlark"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad168187bbb6fc22aa95df6c684b4d1d3345ca36c16f342270e75dbf869b3d8"
+dependencies = [
+ "chrono",
+ "openlark-auth",
+ "openlark-client",
+ "openlark-communication",
+ "openlark-core",
+ "serde",
+ "serde_json",
+ "serde_repr",
+]
+
+[[package]]
+name = "openlark-auth"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fee5a868321d6307fbbda4dd3a127a50b2d90375072bdbc5cbd1e633a545db4"
+dependencies = [
+ "anyhow",
+ "base64 0.22.1",
+ "chrono",
+ "hex",
+ "openlark-core",
+ "rand 0.8.5",
+ "regex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "url",
+ "urlencoding",
+ "uuid",
+]
+
+[[package]]
+name = "openlark-client"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da8bcf21d0d5e5beef862a29c794679d50d5d376e9f9ea4a851e081356628910"
+dependencies = [
+ "chrono",
+ "futures-util",
+ "lark-websocket-protobuf",
+ "log",
+ "openlark-auth",
+ "openlark-communication",
+ "openlark-core",
+ "prost 0.13.5",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-tungstenite 0.23.1",
+ "tracing",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "openlark-communication"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d102d0ad5f63ebbe729e7f513bce18cb0e45bd0da6efb346e8b281ea5b619b1f"
+dependencies = [
+ "openlark-core",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "tracing",
+]
+
+[[package]]
+name = "openlark-core"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "336a10748627ee7c57fc2d80d686b210065670bc7c01a17cacd6e85fd7548975"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "futures-util",
+ "hmac",
+ "http 1.4.0",
+ "num_cpus",
+ "quick_cache",
+ "rand 0.8.5",
+ "regex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "sha2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "urlencoding",
+ "uuid",
+]
+
[[package]]
name = "openssl"
version = "0.10.75"
@@ -6962,7 +7106,7 @@ dependencies = [
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
- "prost",
+ "prost 0.14.3",
"reqwest",
"serde_json",
"thiserror 2.0.18",
@@ -6981,7 +7125,7 @@ dependencies = [
"const-hex",
"opentelemetry",
"opentelemetry_sdk",
- "prost",
+ "prost 0.14.3",
"serde",
"serde_json",
"tonic",
@@ -7050,7 +7194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.45.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7501,6 +7645,26 @@ dependencies = [
"unarray",
]
+[[package]]
+name = "prost"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29"
+dependencies = [
+ "bytes",
+ "prost-derive 0.12.6",
+]
+
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive 0.13.5",
+]
+
[[package]]
name = "prost"
version = "0.14.3"
@@ -7508,7 +7672,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
- "prost-derive",
+ "prost-derive 0.14.3",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
+dependencies = [
+ "bytes",
+ "heck",
+ "itertools 0.10.5",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph 0.6.5",
+ "prettyplease",
+ "prost 0.12.6",
+ "prost-types",
+ "regex",
+ "syn 2.0.114",
+ "tempfile",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1"
+dependencies = [
+ "anyhow",
+ "itertools 0.10.5",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools 0.14.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
]
[[package]]
@@ -7524,6 +7735,15 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "prost-types"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0"
+dependencies = [
+ "prost 0.12.6",
+]
+
[[package]]
name = "psl"
version = "2.1.184"
@@ -7583,6 +7803,18 @@ dependencies = [
"serde",
]
+[[package]]
+name = "quick_cache"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3"
+dependencies = [
+ "ahash",
+ "equivalent",
+ "hashbrown 0.16.1",
+ "parking_lot",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -7596,7 +7828,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls",
- "socket2 0.5.10",
+ "socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -7633,9 +7865,9 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
- "socket2 0.5.10",
+ "socket2 0.6.2",
"tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -7933,7 +8165,7 @@ dependencies = [
"rama-utils",
"rcgen",
"rustls",
- "rustls-native-certs",
+ "rustls-native-certs 0.8.3",
"rustls-pki-types",
"tokio",
"tokio-rustls",
@@ -8238,12 +8470,13 @@ dependencies = [
"js-sys",
"log",
"mime",
+ "mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
- "rustls-native-certs",
+ "rustls-native-certs 0.8.3",
"rustls-pki-types",
"serde",
"serde_json",
@@ -8471,7 +8704,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8490,6 +8723,19 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
+dependencies = [
+ "openssl-probe 0.1.6",
+ "rustls-pemfile",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework 2.11.1",
+]
+
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
@@ -8502,6 +8748,15 @@ dependencies = [
"security-framework 3.5.1",
]
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -9905,7 +10160,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -10103,16 +10358,16 @@ dependencies = [
[[package]]
name = "tiff"
-version = "0.10.3"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
- "zune-jpeg 0.4.21",
+ "zune-jpeg",
]
[[package]]
@@ -10291,6 +10546,22 @@ dependencies = [
"tokio-stream",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
+dependencies = [
+ "futures-util",
+ "log",
+ "rustls",
+ "rustls-native-certs 0.7.3",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tungstenite 0.23.0",
+]
+
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
@@ -10299,11 +10570,11 @@ dependencies = [
"futures-util",
"log",
"rustls",
- "rustls-native-certs",
+ "rustls-native-certs 0.8.3",
"rustls-pki-types",
"tokio",
"tokio-rustls",
- "tungstenite",
+ "tungstenite 0.27.0",
]
[[package]]
@@ -10411,7 +10682,7 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
- "rustls-native-certs",
+ "rustls-native-certs 0.8.3",
"sync_wrapper",
"tokio",
"tokio-rustls",
@@ -10429,7 +10700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0"
dependencies = [
"bytes",
- "prost",
+ "prost 0.14.3",
"tonic",
]
@@ -10687,6 +10958,26 @@ dependencies = [
"termcolor",
]
+[[package]]
+name = "tungstenite"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http 1.4.0",
+ "httparse",
+ "log",
+ "rand 0.8.5",
+ "rustls",
+ "rustls-pki-types",
+ "sha1",
+ "thiserror 1.0.69",
+ "utf-8",
+]
+
[[package]]
name = "tungstenite"
version = "0.27.0"
@@ -11351,7 +11642,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -12333,34 +12624,19 @@ dependencies = [
"pkg-config",
]
-[[package]]
-name = "zune-core"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
-
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
-[[package]]
-name = "zune-jpeg"
-version = "0.4.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
-dependencies = [
- "zune-core 0.4.12",
-]
-
[[package]]
name = "zune-jpeg"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
dependencies = [
- "zune-core 0.5.1",
+ "zune-core",
]
[[package]]
diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml
index 16329276c..865dd8c02 100644
--- a/codex-rs/Cargo.toml
+++ b/codex-rs/Cargo.toml
@@ -20,6 +20,7 @@ members = [
"cloud-tasks-client",
"cloud-tasks-mock-client",
"cli",
+ "clawbot",
"collaboration-mode-templates",
"connectors",
"config",
@@ -115,6 +116,7 @@ codex-async-utils = { path = "async-utils" }
codex-backend-client = { path = "backend-client" }
codex-chatgpt = { path = "chatgpt" }
codex-cli = { path = "cli" }
+codex-clawbot = { path = "clawbot" }
codex-client = { path = "codex-client" }
codex-collaboration-mode-templates = { path = "collaboration-mode-templates" }
codex-cloud-requirements = { path = "cloud-requirements" }
@@ -235,7 +237,7 @@ icu_decimal = "2.1"
icu_locale_core = "2.1"
icu_provider = { version = "2.1", features = ["sync"] }
ignore = "0.4.23"
-image = { version = "^0.25.9", default-features = false }
+image = { version = "^0.25.10", default-features = false }
include_dir = "0.7.4"
indexmap = "2.12.0"
insta = "1.46.3"
diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md
index c24c3fd7e..64f788548 100644
--- a/codex-rs/app-server/README.md
+++ b/codex-rs/app-server/README.md
@@ -994,9 +994,9 @@ Order of messages:
UI guidance for IDEs: surface an approval dialog as soon as the request arrives. The turn will proceed after the server receives a response to the approval request. The terminal `item/completed` notification will be sent with the appropriate status.
-### request_user_input
+### question / request_user_input
-When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup.
+When the client responds to `item/tool/requestUserInput`, whether it originated from the `question` tool or the legacy `request_user_input` tool, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup.
### MCP server elicitations
diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs
index b99d1cb73..126a0043b 100644
--- a/codex-rs/app-server/tests/suite/v2/turn_start.rs
+++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs
@@ -586,7 +586,11 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> {
let payload = request.body_json();
assert_eq!(payload["model"].as_str(), Some("mock-model-collab"));
let payload_text = payload.to_string();
- assert!(payload_text.contains("The `request_user_input` tool is available in Default mode."));
+ assert!(
+ payload_text.contains(
+ "The `question` and `request_user_input` tools are available in Default mode."
+ )
+ );
Ok(())
}
@@ -671,7 +675,11 @@ async fn turn_start_uses_thread_feature_overrides_for_collaboration_mode_instruc
let request = response_mock.single_request();
let payload_text = request.body_json().to_string();
- assert!(payload_text.contains("The `request_user_input` tool is available in Default mode."));
+ assert!(
+ payload_text.contains(
+ "The `question` and `request_user_input` tools are available in Default mode."
+ )
+ );
Ok(())
}
diff --git a/codex-rs/clawbot/BUILD.bazel b/codex-rs/clawbot/BUILD.bazel
new file mode 100644
index 000000000..f559c0e78
--- /dev/null
+++ b/codex-rs/clawbot/BUILD.bazel
@@ -0,0 +1,6 @@
+load("//:defs.bzl", "codex_rust_crate")
+
+codex_rust_crate(
+ name = "clawbot",
+ crate_name = "codex_clawbot",
+)
diff --git a/codex-rs/clawbot/Cargo.toml b/codex-rs/clawbot/Cargo.toml
new file mode 100644
index 000000000..f69f99455
--- /dev/null
+++ b/codex-rs/clawbot/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "codex-clawbot"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lib]
+name = "codex_clawbot"
+path = "src/lib.rs"
+
+[lints]
+workspace = true
+
+[dependencies]
+anyhow = { workspace = true }
+openlark = { version = "0.15.0", default-features = false, features = ["auth", "communication", "websocket"] }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+tokio = { workspace = true, features = ["rt", "sync", "time"] }
+toml = { workspace = true }
+
+[dev-dependencies]
+pretty_assertions = { workspace = true }
+tempfile = { workspace = true }
diff --git a/codex-rs/clawbot/src/bin/feishu-payload-dump.rs b/codex-rs/clawbot/src/bin/feishu-payload-dump.rs
new file mode 100644
index 000000000..b981256d7
--- /dev/null
+++ b/codex-rs/clawbot/src/bin/feishu-payload-dump.rs
@@ -0,0 +1,174 @@
+use std::env;
+use std::fs;
+use std::fs::OpenOptions;
+use std::io::Write;
+use std::path::Path;
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+use std::time::SystemTime;
+use std::time::UNIX_EPOCH;
+
+use anyhow::Context;
+use anyhow::Result;
+use anyhow::anyhow;
+use codex_clawbot::CLAWBOT_RELATIVE_DIR;
+use codex_clawbot::ClawbotRuntime;
+use codex_clawbot::FeishuConfig;
+use open_lark::openlark_client;
+use open_lark::openlark_client::ws_client::EventDispatcherHandler;
+use open_lark::openlark_client::ws_client::LarkWsClient;
+use serde_json::Value;
+use tokio::runtime::Builder;
+use tokio::sync::mpsc;
+
+const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2);
+const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
+const DEFAULT_DUMP_FILE_NAME: &str = "feishu_payload_dump.jsonl";
+
+fn main() -> Result<()> {
+ Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .context("failed to build tokio runtime")?
+ .block_on(async_main())
+}
+
+async fn async_main() -> Result<()> {
+ let workspace_root = workspace_root_from_args()?;
+ let runtime = ClawbotRuntime::load(workspace_root.clone()).with_context(|| {
+ format!(
+ "failed to load clawbot config from {}",
+ workspace_root.display()
+ )
+ })?;
+ let feishu = runtime
+ .snapshot()
+ .config
+ .feishu
+ .clone()
+ .context("missing [feishu] config in .codex/clawbot/config.toml")?;
+ if !feishu.has_api_credentials() {
+ return Err(anyhow!(
+ "missing app_id/app_secret in clawbot feishu config"
+ ));
+ }
+
+ let dump_path = dump_path_from_args(&workspace_root);
+ ensure_parent_dir(&dump_path)?;
+ eprintln!("workspace: {}", workspace_root.display());
+ eprintln!("dump file: {}", dump_path.display());
+
+ let mut reconnect_delay = INITIAL_RECONNECT_DELAY;
+ loop {
+ match run_once(&feishu, &dump_path).await {
+ Ok(()) => {
+ eprintln!(
+ "feishu websocket exited; reconnecting in {}s",
+ reconnect_delay.as_secs()
+ );
+ }
+ Err(err) => {
+ eprintln!(
+ "feishu websocket failed: {err}; reconnecting in {}s",
+ reconnect_delay.as_secs()
+ );
+ }
+ }
+
+ tokio::time::sleep(reconnect_delay).await;
+ reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
+ }
+}
+
+async fn run_once(feishu: &FeishuConfig, dump_path: &Path) -> Result<()> {
+ let ws_config = Arc::new(build_websocket_config(feishu)?);
+ let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>();
+ let dump_path = dump_path.to_path_buf();
+ let payload_task = tokio::spawn(async move {
+ while let Some(payload) = payload_rx.recv().await {
+ if let Err(err) = append_payload_record(&dump_path, &payload) {
+ eprintln!("failed to write payload record: {err}");
+ }
+ }
+ });
+
+ let event_handler = EventDispatcherHandler::builder()
+ .payload_sender(payload_tx)
+ .build();
+ eprintln!("feishu websocket connected; waiting for events...");
+ let result = LarkWsClient::open(ws_config, event_handler)
+ .await
+ .map_err(|err| anyhow!("websocket runtime failed: {err}"));
+ payload_task.abort();
+ let _ = payload_task.await;
+ result
+}
+
+fn workspace_root_from_args() -> Result {
+ Ok(match env::args().nth(1) {
+ Some(path) => PathBuf::from(path),
+ None => env::current_dir().context("failed to resolve current directory")?,
+ })
+}
+
+fn dump_path_from_args(workspace_root: &Path) -> PathBuf {
+ env::args().nth(2).map_or_else(
+ || {
+ workspace_root
+ .join(CLAWBOT_RELATIVE_DIR)
+ .join(DEFAULT_DUMP_FILE_NAME)
+ },
+ PathBuf::from,
+ )
+}
+
+fn ensure_parent_dir(path: &Path) -> Result<()> {
+ let parent = path
+ .parent()
+ .ok_or_else(|| anyhow!("path has no parent: {}", path.display()))?;
+ fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))
+}
+
+fn build_websocket_config(feishu: &FeishuConfig) -> Result {
+ openlark_client::Config::builder()
+ .app_id(feishu.app_id.clone())
+ .app_secret(feishu.app_secret.clone())
+ .timeout(Duration::from_secs(30))
+ .build()
+ .map_err(|err| anyhow!("failed to build websocket config: {err}"))
+}
+
+fn append_payload_record(dump_path: &Path, payload: &[u8]) -> Result<()> {
+ let raw_text = String::from_utf8_lossy(payload).into_owned();
+ let parsed_payload = serde_json::from_slice::(payload).ok();
+ let event_type = parsed_payload
+ .as_ref()
+ .and_then(|value| value.pointer("/header/event_type"))
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned);
+ let recorded_at = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .context("system clock is before UNIX_EPOCH")?
+ .as_secs() as i64;
+ let record = match parsed_payload {
+ Some(payload) => serde_json::json!({
+ "recorded_at": recorded_at,
+ "event_type": event_type,
+ "payload": payload,
+ }),
+ None => serde_json::json!({
+ "recorded_at": recorded_at,
+ "event_type": event_type,
+ "payload_text": raw_text,
+ }),
+ };
+
+ let mut file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(dump_path)
+ .with_context(|| format!("failed to open {}", dump_path.display()))?;
+ writeln!(file, "{}", serde_json::to_string(&record)?)
+ .with_context(|| format!("failed to append {}", dump_path.display()))
+}
diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs
new file mode 100644
index 000000000..4c07757a5
--- /dev/null
+++ b/codex-rs/clawbot/src/config.rs
@@ -0,0 +1,79 @@
+use serde::Deserialize;
+use serde::Serialize;
+
+#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum ClawbotTurnMode {
+ #[default]
+ Interactive,
+ NonInteractive,
+}
+
+impl ClawbotTurnMode {
+ pub fn uses_noninteractive_prompt_handling(self) -> bool {
+ matches!(self, Self::NonInteractive)
+ }
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(default)]
+pub struct ClawbotConfig {
+ pub feishu: Option,
+ pub turn_mode: ClawbotTurnMode,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(default)]
+pub struct FeishuConfig {
+ pub app_id: String,
+ pub app_secret: String,
+ pub verification_token: Option,
+ pub encrypt_key: Option,
+ pub bot_open_id: Option,
+ pub bot_user_id: Option,
+}
+
+impl FeishuConfig {
+ pub fn has_api_credentials(&self) -> bool {
+ !self.app_id.trim().is_empty() && !self.app_secret.trim().is_empty()
+ }
+
+ pub fn is_bot_sender(
+ &self,
+ open_id: Option<&str>,
+ user_id: Option<&str>,
+ app_id: Option<&str>,
+ ) -> bool {
+ self.bot_open_id
+ .as_deref()
+ .zip(open_id)
+ .is_some_and(|(bot_open_id, sender_open_id)| bot_open_id == sender_open_id)
+ || self
+ .bot_user_id
+ .as_deref()
+ .zip(user_id)
+ .is_some_and(|(bot_user_id, sender_user_id)| bot_user_id == sender_user_id)
+ || app_id.is_some_and(|sender_app_id| sender_app_id == self.app_id)
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.app_id.trim().is_empty()
+ && self.app_secret.trim().is_empty()
+ && self
+ .verification_token
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ && self
+ .encrypt_key
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ && self
+ .bot_open_id
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ && self
+ .bot_user_id
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ }
+}
diff --git a/codex-rs/clawbot/src/diagnostics.rs b/codex-rs/clawbot/src/diagnostics.rs
new file mode 100644
index 000000000..bd32bfe1d
--- /dev/null
+++ b/codex-rs/clawbot/src/diagnostics.rs
@@ -0,0 +1,50 @@
+use std::fs::OpenOptions;
+use std::io::Write;
+use std::path::Path;
+use std::time::SystemTime;
+use std::time::UNIX_EPOCH;
+
+use anyhow::Context;
+use anyhow::Result;
+use serde::Serialize;
+
+use crate::model::CLAWBOT_DIAGNOSTICS_RELATIVE_PATH;
+
+#[derive(Debug, Serialize)]
+struct DiagnosticEvent {
+ ts_ms: i64,
+ kind: String,
+ payload: T,
+}
+
+pub fn append_diagnostic_event(workspace_root: &Path, kind: &str, payload: T) -> Result<()>
+where
+ T: Serialize,
+{
+ let path = workspace_root.join(CLAWBOT_DIAGNOSTICS_RELATIVE_PATH);
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)
+ .with_context(|| format!("failed to create {}", parent.display()))?;
+ }
+ let event = DiagnosticEvent {
+ ts_ms: unix_timestamp_ms_now()?,
+ kind: kind.to_string(),
+ payload,
+ };
+ let mut file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&path)
+ .with_context(|| format!("failed to open {}", path.display()))?;
+ serde_json::to_writer(&mut file, &event)
+ .context("failed to encode clawbot diagnostic event")?;
+ file.write_all(b"\n")
+ .with_context(|| format!("failed to append {}", path.display()))
+}
+
+fn unix_timestamp_ms_now() -> Result {
+ Ok(SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .context("system clock is before unix epoch")?
+ .as_millis() as i64)
+}
diff --git a/codex-rs/clawbot/src/events.rs b/codex-rs/clawbot/src/events.rs
new file mode 100644
index 000000000..d66710856
--- /dev/null
+++ b/codex-rs/clawbot/src/events.rs
@@ -0,0 +1,12 @@
+use serde::Deserialize;
+use serde::Serialize;
+
+use crate::model::ProviderSessionRef;
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ProviderInboundMessage {
+ pub session: ProviderSessionRef,
+ pub message_id: String,
+ pub text: String,
+ pub received_at: i64,
+}
diff --git a/codex-rs/clawbot/src/lib.rs b/codex-rs/clawbot/src/lib.rs
new file mode 100644
index 000000000..92645f5cc
--- /dev/null
+++ b/codex-rs/clawbot/src/lib.rs
@@ -0,0 +1,42 @@
+mod config;
+mod diagnostics;
+mod events;
+mod model;
+mod provider;
+mod runtime;
+mod store;
+
+pub use config::ClawbotConfig;
+pub use config::ClawbotTurnMode;
+pub use config::FeishuConfig;
+pub use diagnostics::append_diagnostic_event;
+pub use events::ProviderInboundMessage;
+pub use model::CLAWBOT_BINDINGS_RELATIVE_PATH;
+pub use model::CLAWBOT_CONFIG_RELATIVE_PATH;
+pub use model::CLAWBOT_DIAGNOSTICS_RELATIVE_PATH;
+pub use model::CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH;
+pub use model::CLAWBOT_PENDING_TURNS_RELATIVE_PATH;
+pub use model::CLAWBOT_RELATIVE_DIR;
+pub use model::CLAWBOT_RUNTIME_RELATIVE_PATH;
+pub use model::CLAWBOT_SESSIONS_RELATIVE_PATH;
+pub use model::CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH;
+pub use model::CachedUnreadMessage;
+pub use model::ClawbotSnapshot;
+pub use model::ConnectionStatus;
+pub use model::ForwardingDirection;
+pub use model::ForwardingState;
+pub use model::PendingClawbotTurn;
+pub use model::ProviderKind;
+pub use model::ProviderMessageRef;
+pub use model::ProviderRuntimeState;
+pub use model::ProviderSession;
+pub use model::ProviderSessionRef;
+pub use model::SessionBinding;
+pub use model::SessionStatus;
+pub use provider::FeishuProviderRuntime;
+pub use provider::ProviderEvent;
+pub use provider::ProviderOutboundReaction;
+pub use provider::ProviderOutboundTextMessage;
+pub use provider::feishu_failure_reply_text;
+pub use runtime::ClawbotRuntime;
+pub use store::ClawbotStore;
diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs
new file mode 100644
index 000000000..a21e0ff18
--- /dev/null
+++ b/codex-rs/clawbot/src/model.rs
@@ -0,0 +1,204 @@
+use serde::Deserialize;
+use serde::Serialize;
+
+use crate::config::ClawbotConfig;
+use crate::config::ClawbotTurnMode;
+
+pub const CLAWBOT_RELATIVE_DIR: &str = ".codex/clawbot";
+pub const CLAWBOT_CONFIG_RELATIVE_PATH: &str = ".codex/clawbot/config.toml";
+pub const CLAWBOT_SESSIONS_RELATIVE_PATH: &str = ".codex/clawbot/sessions.json";
+pub const CLAWBOT_BINDINGS_RELATIVE_PATH: &str = ".codex/clawbot/bindings.json";
+pub const CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH: &str = ".codex/clawbot/unread_messages.jsonl";
+pub const CLAWBOT_PENDING_TURNS_RELATIVE_PATH: &str = ".codex/clawbot/pending_turns.json";
+pub const CLAWBOT_RUNTIME_RELATIVE_PATH: &str = ".codex/clawbot/runtime.json";
+pub const CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH: &str = ".codex/clawbot/inbound_receipts.json";
+pub const CLAWBOT_DIAGNOSTICS_RELATIVE_PATH: &str = ".codex/clawbot/diagnostics.jsonl";
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
+pub struct ClawbotSnapshot {
+ pub config: ClawbotConfig,
+ pub runtime: Vec,
+ pub sessions: Vec,
+ pub bindings: Vec,
+ pub unread_message_count: usize,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
+#[serde(rename_all = "snake_case")]
+pub enum ProviderKind {
+ Feishu,
+}
+
+impl ProviderKind {
+ pub fn title(self) -> &'static str {
+ match self {
+ Self::Feishu => "Feishu",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum ConnectionStatus {
+ #[default]
+ Unconfigured,
+ Disconnected,
+ Connecting,
+ Connected,
+ Error,
+}
+
+#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum SessionStatus {
+ #[default]
+ Discovered,
+ Bound,
+ Disconnected,
+ Error,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ProviderRuntimeState {
+ pub provider: ProviderKind,
+ pub connection: ConnectionStatus,
+ pub last_error: Option,
+ pub updated_at: Option,
+}
+
+impl ProviderRuntimeState {
+ pub fn unconfigured(provider: ProviderKind) -> Self {
+ Self {
+ provider,
+ connection: ConnectionStatus::Unconfigured,
+ last_error: None,
+ updated_at: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
+pub struct ProviderSessionRef {
+ pub provider: ProviderKind,
+ pub session_id: String,
+}
+
+impl ProviderSessionRef {
+ pub fn new(provider: ProviderKind, session_id: impl Into) -> Self {
+ Self {
+ provider,
+ session_id: session_id.into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
+pub struct ProviderMessageRef {
+ pub provider: ProviderKind,
+ pub session_id: String,
+ pub message_id: String,
+}
+
+impl ProviderMessageRef {
+ pub fn new(
+ provider: ProviderKind,
+ session_id: impl Into,
+ message_id: impl Into,
+ ) -> Self {
+ Self {
+ provider,
+ session_id: session_id.into(),
+ message_id: message_id.into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ProviderSession {
+ pub provider: ProviderKind,
+ pub session_id: String,
+ pub display_name: Option,
+ pub unread_count: usize,
+ pub last_message_at: Option,
+ pub status: SessionStatus,
+ pub bound_thread_id: Option,
+}
+
+impl ProviderSession {
+ pub fn session_ref(&self) -> ProviderSessionRef {
+ ProviderSessionRef::new(self.provider, self.session_id.clone())
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct SessionBinding {
+ pub provider: ProviderKind,
+ pub session_id: String,
+ pub thread_id: String,
+ #[serde(default = "default_session_forwarding_enabled")]
+ pub inbound_forwarding_enabled: bool,
+ #[serde(default = "default_session_forwarding_enabled")]
+ pub outbound_forwarding_enabled: bool,
+ pub created_at: i64,
+ pub updated_at: i64,
+}
+
+impl SessionBinding {
+ pub fn session_ref(&self) -> ProviderSessionRef {
+ ProviderSessionRef::new(self.provider, self.session_id.clone())
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ForwardingDirection {
+ Inbound,
+ Outbound,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ForwardingState {
+ Enabled,
+ Disabled,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct CachedUnreadMessage {
+ pub provider: ProviderKind,
+ pub session_id: String,
+ pub message_id: String,
+ pub text: String,
+ pub received_at: i64,
+}
+
+impl CachedUnreadMessage {
+ pub fn session_ref(&self) -> ProviderSessionRef {
+ ProviderSessionRef::new(self.provider, self.session_id.clone())
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct PendingClawbotTurn {
+ pub thread_id: String,
+ pub turn_id: String,
+ pub session: ProviderSessionRef,
+ pub message_id: String,
+ pub turn_mode: ClawbotTurnMode,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct InboundMessageReceipt {
+ pub provider: ProviderKind,
+ pub session_id: String,
+ pub message_id: String,
+ pub received_at: i64,
+}
+
+impl InboundMessageReceipt {
+ pub fn session_ref(&self) -> ProviderSessionRef {
+ ProviderSessionRef::new(self.provider, self.session_id.clone())
+ }
+}
+
+fn default_session_forwarding_enabled() -> bool {
+ true
+}
diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs
new file mode 100644
index 000000000..772bb417b
--- /dev/null
+++ b/codex-rs/clawbot/src/provider/feishu.rs
@@ -0,0 +1,917 @@
+mod runtime_loop;
+mod sync;
+
+use std::path::Path;
+use std::path::PathBuf;
+use std::time::SystemTime;
+use std::time::UNIX_EPOCH;
+
+use anyhow::Result;
+use anyhow::anyhow;
+use open_lark::openlark_client;
+use open_lark::openlark_communication::common::api_utils::serialize_params;
+use open_lark::openlark_communication::endpoints::IM_V1_MESSAGES;
+use open_lark::openlark_communication::im::im::v1::message::create::CreateMessageBody;
+use open_lark::openlark_communication::im::im::v1::message::create::CreateMessageRequest;
+use open_lark::openlark_communication::im::im::v1::message::models::ReceiveIdType;
+use open_lark::openlark_communication::im::im::v1::message::reaction::models::CreateMessageReactionBody;
+use open_lark::openlark_communication::im::im::v1::message::reaction::models::ReactionType;
+use open_lark::openlark_core::api::ApiRequest;
+use serde::Deserialize;
+use serde_json::Value;
+use tokio::sync::mpsc;
+
+use super::ProviderEvent;
+use super::ProviderOutboundReaction;
+use super::ProviderOutboundTextMessage;
+use crate::append_diagnostic_event;
+use crate::config::FeishuConfig;
+use crate::events::ProviderInboundMessage;
+use crate::model::ConnectionStatus;
+use crate::model::ProviderKind;
+use crate::model::ProviderRuntimeState;
+use crate::model::ProviderSession;
+use crate::model::ProviderSessionRef;
+use crate::model::SessionStatus;
+
+#[derive(Debug, Clone)]
+pub struct FeishuInboundMessage {
+ pub chat_id: String,
+ pub chat_type: String,
+ pub chat_name: Option,
+ pub message_id: String,
+ pub sender_open_id: Option,
+ pub sender_user_id: Option,
+ pub sender_union_id: Option,
+ pub text: String,
+ pub received_at: i64,
+}
+
+#[derive(Debug, Clone)]
+pub struct FeishuProviderRuntime {
+ workspace_root: PathBuf,
+ config: FeishuConfig,
+}
+
+impl FeishuProviderRuntime {
+ pub fn new(workspace_root: impl Into, config: FeishuConfig) -> Self {
+ Self {
+ workspace_root: workspace_root.into(),
+ config,
+ }
+ }
+
+ pub async fn run(self, provider_event_tx: mpsc::UnboundedSender) -> Result<()> {
+ runtime_loop::run_with_reconnect(self.workspace_root, self.config, provider_event_tx).await
+ }
+
+ pub async fn send_text(&self, message: ProviderOutboundTextMessage) -> Result<()> {
+ if message.session.provider != ProviderKind::Feishu {
+ return Err(anyhow!(
+ "cannot send {} message via Feishu runtime",
+ message.session.provider.title()
+ ));
+ }
+
+ let text = message.text;
+ let session_id = message.session.session_id;
+ let response = CreateMessageRequest::new(self.messaging_config()?)
+ .receive_id_type(ReceiveIdType::ChatId)
+ .execute(CreateMessageBody {
+ receive_id: session_id.clone(),
+ msg_type: "text".to_string(),
+ content: serde_json::to_string(&serde_json::json!({ "text": text.clone() }))?,
+ uuid: None,
+ })
+ .await
+ .map_err(|error| anyhow!("failed to send Feishu text message: {error}"));
+ match response {
+ Ok(_) => {
+ let _ = append_diagnostic_event(
+ self.workspace_root.as_path(),
+ "feishu.send_text_succeeded",
+ serde_json::json!({
+ "session_id": session_id,
+ "text": text,
+ }),
+ );
+ Ok(())
+ }
+ Err(error) => {
+ let _ = append_diagnostic_event(
+ self.workspace_root.as_path(),
+ "feishu.send_text_failed",
+ serde_json::json!({
+ "session_id": session_id,
+ "text": text,
+ "error": error.to_string(),
+ }),
+ );
+ Err(error)
+ }
+ }
+ }
+
+ pub async fn add_reaction(&self, reaction: ProviderOutboundReaction) -> Result<()> {
+ if reaction.target.provider != ProviderKind::Feishu {
+ return Err(anyhow!(
+ "cannot send {} reaction via Feishu runtime",
+ reaction.target.provider.title()
+ ));
+ }
+
+ let request: ApiRequest = ApiRequest::post(format!(
+ "{IM_V1_MESSAGES}/{}/reactions",
+ reaction.target.message_id
+ ))
+ .body(serialize_params(
+ &CreateMessageReactionBody {
+ reaction_type: ReactionType {
+ emoji_type: reaction.emoji_type,
+ },
+ },
+ "添加消息表情回复",
+ )?);
+ let response = open_lark::openlark_core::http::Transport::::request(
+ request,
+ &self.messaging_config()?,
+ Some(Default::default()),
+ )
+ .await
+ .map_err(|error| anyhow!("failed to add Feishu message reaction: {error}"))?;
+ if response.is_success() {
+ Ok(())
+ } else {
+ Err(anyhow!(
+ "failed to add Feishu message reaction: {}",
+ response.msg()
+ ))
+ }
+ }
+
+ pub async fn scan_sessions(&self) -> Result> {
+ let sessions = sync::discover_supported_sessions(&self.messaging_config()?).await?;
+ Ok(sessions
+ .into_iter()
+ .map(ProviderEvent::SessionUpserted)
+ .collect())
+ }
+
+ pub fn normalize_chat_message(message: FeishuInboundMessage) -> Option> {
+ if !is_supported_chat_type(&message.chat_type) || message.text.trim().is_empty() {
+ return None;
+ }
+
+ let display_name = if is_group_chat_type(&message.chat_type) {
+ message.chat_name
+ } else {
+ message
+ .chat_name
+ .or(message.sender_open_id.clone())
+ .or(message.sender_user_id.clone())
+ .or(message.sender_union_id.clone())
+ };
+ let session = ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: message.chat_id.clone(),
+ display_name,
+ unread_count: 0,
+ last_message_at: Some(message.received_at),
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ };
+ let inbound_message = ProviderInboundMessage {
+ session: ProviderSessionRef::new(ProviderKind::Feishu, message.chat_id),
+ message_id: message.message_id,
+ text: message.text,
+ received_at: message.received_at,
+ };
+
+ Some(vec![
+ ProviderEvent::SessionUpserted(session),
+ ProviderEvent::InboundMessage(inbound_message),
+ ])
+ }
+
+ pub(super) fn websocket_config(&self) -> Result {
+ runtime_loop::build_websocket_config(&self.config)
+ }
+
+ fn messaging_config(&self) -> Result {
+ Ok(self
+ .websocket_config()?
+ .build_core_config_with_token_provider())
+ }
+}
+
+pub fn failure_reply_text(message: &str) -> String {
+ let summary = message
+ .lines()
+ .map(str::trim)
+ .find(|line| !line.is_empty())
+ .unwrap_or("unknown error");
+ let truncated = truncate_chars(summary, /*max_chars*/ 160);
+ format!("Request failed: {truncated}")
+}
+
+pub(super) fn provider_events_from_payload(
+ payload: &[u8],
+ config: &FeishuConfig,
+ workspace_root: &Path,
+) -> Vec {
+ let Ok(envelope) = serde_json::from_slice::(payload) else {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.payload_parse_failed",
+ serde_json::json!({
+ "payload": String::from_utf8_lossy(payload),
+ }),
+ );
+ return Vec::new();
+ };
+
+ match envelope.header.event_type.as_str() {
+ "im.message.receive_v1" => {
+ serde_json::from_value::(envelope.event)
+ .ok()
+ .and_then(|event| {
+ normalize_message_receive_event(
+ FeishuMessageReceiveEnvelope { event },
+ config,
+ workspace_root,
+ )
+ })
+ .unwrap_or_default()
+ }
+ "im.chat.access_event.bot_p2p_chat_entered_v1" => {
+ serde_json::from_value::(envelope.event)
+ .ok()
+ .map(|event| normalize_chat_entered_event(FeishuChatEnteredEnvelope { event }))
+ .unwrap_or_default()
+ }
+ _ => {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.unsupported_event",
+ serde_json::json!({
+ "event_type": envelope.header.event_type,
+ }),
+ );
+ Vec::new()
+ }
+ }
+}
+
+fn normalize_message_receive_event(
+ envelope: FeishuMessageReceiveEnvelope,
+ config: &FeishuConfig,
+ workspace_root: &Path,
+) -> Option> {
+ let chat = envelope.event.chat;
+ let message = envelope.event.message;
+ let chat_type = message
+ .chat_type
+ .clone()
+ .or(chat.as_ref().and_then(|chat| chat.chat_type.clone()));
+ let message_type = message
+ .message_type
+ .as_deref()
+ .or(message.msg_type.as_deref());
+ let sender = envelope.event.sender.sender_id;
+ let Some(chat_type) = chat_type else {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.message_dropped",
+ serde_json::json!({
+ "reason": "missing_chat_type",
+ "message_id": message.message_id,
+ }),
+ );
+ return None;
+ };
+ let Some(message_type) = message_type else {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.message_dropped",
+ serde_json::json!({
+ "reason": "missing_message_type",
+ "chat_id": message.chat_id,
+ "message_id": message.message_id,
+ "chat_type": chat_type,
+ }),
+ );
+ return None;
+ };
+ if !is_supported_chat_type(&chat_type) {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.message_dropped",
+ serde_json::json!({
+ "reason": "unsupported_chat_type",
+ "chat_id": message.chat_id,
+ "message_id": message.message_id,
+ "chat_type": chat_type,
+ }),
+ );
+ return None;
+ }
+ if message_type != "text" {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.message_dropped",
+ serde_json::json!({
+ "reason": "unsupported_message_type",
+ "chat_id": message.chat_id,
+ "message_id": message.message_id,
+ "chat_type": chat_type,
+ "message_type": message_type,
+ }),
+ );
+ return None;
+ }
+ if config.is_bot_sender(
+ sender.open_id.as_deref(),
+ sender.user_id.as_deref(),
+ sender.app_id.as_deref(),
+ ) {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.message_dropped",
+ serde_json::json!({
+ "reason": "bot_sender",
+ "chat_id": message.chat_id,
+ "message_id": message.message_id,
+ "chat_type": chat_type,
+ "sender_open_id": sender.open_id,
+ "sender_user_id": sender.user_id,
+ "sender_app_id": sender.app_id,
+ }),
+ );
+ return None;
+ }
+
+ let chat_id = chat
+ .as_ref()
+ .map(|chat| chat.chat_id.clone())
+ .or(message.chat_id.clone())?;
+ let raw_content = message
+ .content
+ .or(message.body.and_then(|body| body.content))
+ .unwrap_or_default();
+ let text = serde_json::from_str::(&raw_content)
+ .ok()
+ .map(|content| content.text)
+ .unwrap_or(raw_content);
+ let normalized_text = if is_group_chat_type(&chat_type) {
+ strip_group_mention_prefix(&text)
+ } else {
+ text
+ };
+ let received_at = parse_timestamp_value(message.create_time)?;
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.message_normalized",
+ serde_json::json!({
+ "chat_id": chat_id.clone(),
+ "chat_type": chat_type.clone(),
+ "message_id": message.message_id.clone(),
+ "sender_open_id": sender.open_id.clone(),
+ "sender_user_id": sender.user_id.clone(),
+ "sender_app_id": sender.app_id.clone(),
+ "text": normalized_text.clone(),
+ }),
+ );
+
+ FeishuProviderRuntime::normalize_chat_message(FeishuInboundMessage {
+ chat_id,
+ chat_type,
+ chat_name: chat.and_then(|chat| chat.name),
+ message_id: message.message_id,
+ sender_open_id: sender.open_id,
+ sender_user_id: sender.user_id,
+ sender_union_id: sender.union_id,
+ text: normalized_text,
+ received_at,
+ })
+}
+
+fn normalize_chat_entered_event(envelope: FeishuChatEnteredEnvelope) -> Vec {
+ let operator = envelope.event.operator_id;
+ vec![ProviderEvent::SessionUpserted(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: envelope.event.chat_id,
+ display_name: operator
+ .open_id
+ .clone()
+ .or(operator.user_id.clone())
+ .or(operator.union_id),
+ unread_count: 0,
+ last_message_at: parse_optional_timestamp(envelope.event.last_message_create_time),
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ })]
+}
+
+fn parse_optional_timestamp(timestamp: Option) -> Option {
+ timestamp.and_then(|value| value.parse::().ok())
+}
+
+fn parse_timestamp_value(timestamp: serde_json::Value) -> Option {
+ match timestamp {
+ serde_json::Value::String(value) => parse_optional_timestamp(Some(value)),
+ serde_json::Value::Number(value) => value.as_i64(),
+ _ => None,
+ }
+}
+
+fn is_supported_chat_type(chat_type: &str) -> bool {
+ is_private_chat_type(chat_type) || is_group_chat_type(chat_type)
+}
+
+fn is_private_chat_type(chat_type: &str) -> bool {
+ matches!(chat_type, "p2p" | "private")
+}
+
+fn is_group_chat_type(chat_type: &str) -> bool {
+ matches!(chat_type, "group")
+}
+
+fn strip_group_mention_prefix(text: &str) -> String {
+ let mut remaining = text.trim_start();
+ let mut stripped = false;
+
+ loop {
+ let Some(after_at) = remaining.strip_prefix('@') else {
+ break;
+ };
+ let mention_len = after_at
+ .char_indices()
+ .find_map(|(idx, ch)| {
+ (ch.is_whitespace() || matches!(ch, ':' | ':' | ',' | ',')).then_some(idx)
+ })
+ .unwrap_or(after_at.len());
+ if mention_len == 0 {
+ break;
+ }
+ remaining = &after_at[mention_len..];
+ remaining = remaining.trim_start_matches(|ch: char| {
+ ch.is_whitespace() || matches!(ch, ':' | ':' | ',' | ',')
+ });
+ stripped = true;
+ }
+
+ if stripped {
+ remaining.to_string()
+ } else {
+ text.to_string()
+ }
+}
+
+pub(super) fn runtime_state(
+ connection: ConnectionStatus,
+ last_error: Option,
+) -> Result {
+ Ok(ProviderRuntimeState {
+ provider: ProviderKind::Feishu,
+ connection,
+ last_error,
+ updated_at: Some(unix_timestamp_now()?),
+ })
+}
+
+fn unix_timestamp_now() -> Result {
+ Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64)
+}
+
+fn truncate_chars(value: &str, max_chars: usize) -> String {
+ let mut chars = value.chars();
+ let truncated: String = chars.by_ref().take(max_chars).collect();
+ if chars.next().is_some() {
+ format!("{truncated}…")
+ } else {
+ truncated
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuEventEnvelope {
+ header: FeishuEventHeader,
+ event: serde_json::Value,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuEventHeader {
+ event_type: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuMessageReceiveEnvelope {
+ event: FeishuMessageReceiveEvent,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuMessageReceiveEvent {
+ sender: FeishuEventSender,
+ message: FeishuEventMessage,
+ #[serde(default)]
+ chat: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuEventSender {
+ sender_id: FeishuUserId,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuUserId {
+ open_id: Option,
+ user_id: Option,
+ union_id: Option,
+ #[serde(default)]
+ app_id: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuEventMessage {
+ message_id: String,
+ create_time: serde_json::Value,
+ #[serde(default)]
+ chat_id: Option,
+ #[serde(default)]
+ chat_type: Option,
+ #[serde(default)]
+ message_type: Option,
+ #[serde(default)]
+ msg_type: Option,
+ #[serde(default)]
+ content: Option,
+ #[serde(default)]
+ body: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuEventChat {
+ chat_id: String,
+ #[serde(default)]
+ chat_type: Option,
+ #[serde(default)]
+ name: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuTextContent {
+ text: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuEventMessageBody {
+ content: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuChatEnteredEnvelope {
+ event: FeishuChatEnteredEvent,
+}
+
+#[derive(Debug, Deserialize)]
+struct FeishuChatEnteredEvent {
+ chat_id: String,
+ operator_id: FeishuUserId,
+ last_message_create_time: Option,
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::Path;
+
+ use pretty_assertions::assert_eq;
+
+ use super::FeishuInboundMessage;
+ use super::failure_reply_text;
+ use super::normalize_chat_entered_event;
+ use super::normalize_message_receive_event;
+ use super::strip_group_mention_prefix;
+ use crate::config::FeishuConfig;
+ use crate::model::ProviderKind;
+ use crate::model::ProviderSession;
+ use crate::model::ProviderSessionRef;
+ use crate::model::SessionStatus;
+ use crate::provider::ProviderEvent;
+
+ #[test]
+ fn normalize_chat_message_creates_session_and_inbound_events() {
+ let events = super::FeishuProviderRuntime::normalize_chat_message(FeishuInboundMessage {
+ chat_id: "chat_123".to_string(),
+ chat_type: "p2p".to_string(),
+ chat_name: Some("Alice".to_string()),
+ message_id: "msg_123".to_string(),
+ sender_open_id: Some("ou_123".to_string()),
+ sender_user_id: None,
+ sender_union_id: None,
+ text: "hello".to_string(),
+ received_at: 123,
+ })
+ .expect("events");
+
+ assert_eq!(
+ events,
+ vec![
+ ProviderEvent::SessionUpserted(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_123".to_string(),
+ display_name: Some("Alice".to_string()),
+ unread_count: 0,
+ last_message_at: Some(123),
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ }),
+ ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage {
+ session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_123"),
+ message_id: "msg_123".to_string(),
+ text: "hello".to_string(),
+ received_at: 123,
+ }),
+ ]
+ );
+ }
+
+ #[test]
+ fn message_receive_event_skips_non_text_messages() {
+ let envelope = super::FeishuMessageReceiveEnvelope {
+ event: super::FeishuMessageReceiveEvent {
+ sender: super::FeishuEventSender {
+ sender_id: super::FeishuUserId {
+ open_id: Some("ou_123".to_string()),
+ user_id: None,
+ union_id: None,
+ app_id: None,
+ },
+ },
+ message: super::FeishuEventMessage {
+ message_id: "msg_123".to_string(),
+ create_time: serde_json::json!("456"),
+ chat_id: Some("chat_123".to_string()),
+ chat_type: Some("p2p".to_string()),
+ message_type: Some("image".to_string()),
+ msg_type: None,
+ content: Some("{}".to_string()),
+ body: None,
+ },
+ chat: None,
+ },
+ };
+
+ assert_eq!(
+ normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")),
+ None
+ );
+ }
+
+ #[test]
+ fn message_receive_event_accepts_group_text_messages() {
+ let envelope = super::FeishuMessageReceiveEnvelope {
+ event: super::FeishuMessageReceiveEvent {
+ sender: super::FeishuEventSender {
+ sender_id: super::FeishuUserId {
+ open_id: Some("ou_member".to_string()),
+ user_id: None,
+ union_id: None,
+ app_id: None,
+ },
+ },
+ message: super::FeishuEventMessage {
+ message_id: "msg_group_1".to_string(),
+ create_time: serde_json::json!("456"),
+ chat_id: Some("chat_group_123".to_string()),
+ chat_type: Some("group".to_string()),
+ message_type: Some("text".to_string()),
+ msg_type: None,
+ content: Some("{\"text\":\"hello group\"}".to_string()),
+ body: None,
+ },
+ chat: Some(super::FeishuEventChat {
+ chat_id: "chat_group_123".to_string(),
+ chat_type: Some("group".to_string()),
+ name: Some("tracker".to_string()),
+ }),
+ },
+ };
+
+ assert_eq!(
+ normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")),
+ Some(vec![
+ ProviderEvent::SessionUpserted(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_group_123".to_string(),
+ display_name: Some("tracker".to_string()),
+ unread_count: 0,
+ last_message_at: Some(456),
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ }),
+ ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage {
+ session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_group_123"),
+ message_id: "msg_group_1".to_string(),
+ text: "hello group".to_string(),
+ received_at: 456,
+ }),
+ ])
+ );
+ }
+
+ #[test]
+ fn message_receive_event_skips_bot_authored_messages() {
+ let envelope = super::FeishuMessageReceiveEnvelope {
+ event: super::FeishuMessageReceiveEvent {
+ sender: super::FeishuEventSender {
+ sender_id: super::FeishuUserId {
+ open_id: Some("ou_bot".to_string()),
+ user_id: None,
+ union_id: None,
+ app_id: None,
+ },
+ },
+ message: super::FeishuEventMessage {
+ message_id: "msg_bot_1".to_string(),
+ create_time: serde_json::json!("456"),
+ chat_id: Some("chat_group_123".to_string()),
+ chat_type: Some("group".to_string()),
+ message_type: Some("text".to_string()),
+ msg_type: None,
+ content: Some("{\"text\":\"hello group\"}".to_string()),
+ body: None,
+ },
+ chat: Some(super::FeishuEventChat {
+ chat_id: "chat_group_123".to_string(),
+ chat_type: Some("group".to_string()),
+ name: Some("tracker".to_string()),
+ }),
+ },
+ };
+
+ assert_eq!(
+ normalize_message_receive_event(
+ envelope,
+ &FeishuConfig {
+ bot_open_id: Some("ou_bot".to_string()),
+ ..FeishuConfig::default()
+ },
+ Path::new("/tmp"),
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn message_receive_event_skips_app_authored_messages() {
+ let envelope = super::FeishuMessageReceiveEnvelope {
+ event: super::FeishuMessageReceiveEvent {
+ sender: super::FeishuEventSender {
+ sender_id: super::FeishuUserId {
+ open_id: None,
+ user_id: None,
+ union_id: None,
+ app_id: Some("cli_app_123".to_string()),
+ },
+ },
+ message: super::FeishuEventMessage {
+ message_id: "msg_bot_app_1".to_string(),
+ create_time: serde_json::json!("456"),
+ chat_id: Some("chat_group_123".to_string()),
+ chat_type: Some("group".to_string()),
+ message_type: Some("text".to_string()),
+ msg_type: None,
+ content: Some("{\"text\":\"hello group\"}".to_string()),
+ body: None,
+ },
+ chat: Some(super::FeishuEventChat {
+ chat_id: "chat_group_123".to_string(),
+ chat_type: Some("group".to_string()),
+ name: Some("tracker".to_string()),
+ }),
+ },
+ };
+
+ assert_eq!(
+ normalize_message_receive_event(
+ envelope,
+ &FeishuConfig {
+ app_id: "cli_app_123".to_string(),
+ ..FeishuConfig::default()
+ },
+ Path::new("/tmp"),
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn message_receive_event_uses_chat_fallbacks_for_group_payloads() {
+ let envelope = super::FeishuMessageReceiveEnvelope {
+ event: super::FeishuMessageReceiveEvent {
+ sender: super::FeishuEventSender {
+ sender_id: super::FeishuUserId {
+ open_id: Some("ou_member".to_string()),
+ user_id: None,
+ union_id: None,
+ app_id: None,
+ },
+ },
+ message: super::FeishuEventMessage {
+ message_id: "msg_group_fallback_1".to_string(),
+ create_time: serde_json::json!(456),
+ chat_id: None,
+ chat_type: None,
+ message_type: None,
+ msg_type: Some("text".to_string()),
+ content: None,
+ body: Some(super::FeishuEventMessageBody {
+ content: Some("{\"text\":\"hello fallback\"}".to_string()),
+ }),
+ },
+ chat: Some(super::FeishuEventChat {
+ chat_id: "chat_group_fallback_123".to_string(),
+ chat_type: Some("group".to_string()),
+ name: Some("tracker".to_string()),
+ }),
+ },
+ };
+
+ assert_eq!(
+ normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")),
+ Some(vec![
+ ProviderEvent::SessionUpserted(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_group_fallback_123".to_string(),
+ display_name: Some("tracker".to_string()),
+ unread_count: 0,
+ last_message_at: Some(456),
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ }),
+ ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage {
+ session: ProviderSessionRef::new(
+ ProviderKind::Feishu,
+ "chat_group_fallback_123",
+ ),
+ message_id: "msg_group_fallback_1".to_string(),
+ text: "hello fallback".to_string(),
+ received_at: 456,
+ }),
+ ])
+ );
+ }
+
+ #[test]
+ fn chat_entered_event_creates_discovered_session() {
+ let events = normalize_chat_entered_event(super::FeishuChatEnteredEnvelope {
+ event: super::FeishuChatEnteredEvent {
+ chat_id: "chat_123".to_string(),
+ operator_id: super::FeishuUserId {
+ open_id: Some("ou_123".to_string()),
+ user_id: None,
+ union_id: None,
+ app_id: None,
+ },
+ last_message_create_time: Some("456".to_string()),
+ },
+ });
+
+ assert_eq!(
+ events,
+ vec![ProviderEvent::SessionUpserted(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_123".to_string(),
+ display_name: Some("ou_123".to_string()),
+ unread_count: 0,
+ last_message_at: Some(456),
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ })]
+ );
+ }
+
+ #[test]
+ fn failure_reply_text_uses_first_nonempty_line() {
+ assert_eq!(
+ failure_reply_text("\nboom\nsecond"),
+ "Request failed: boom".to_string()
+ );
+ }
+
+ #[test]
+ fn strip_group_mention_prefix_removes_leading_mentions() {
+ assert_eq!(
+ strip_group_mention_prefix("@_user_1 TRACKER TEST 2"),
+ "TRACKER TEST 2".to_string()
+ );
+ assert_eq!(
+ strip_group_mention_prefix("@bot: hello"),
+ "hello".to_string()
+ );
+ assert_eq!(
+ strip_group_mention_prefix("@bot @helper ping"),
+ "ping".to_string()
+ );
+ }
+}
diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs
new file mode 100644
index 000000000..1e1537c6d
--- /dev/null
+++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs
@@ -0,0 +1,209 @@
+use std::path::Path;
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use anyhow::Result;
+use anyhow::anyhow;
+use open_lark::openlark_client;
+use open_lark::openlark_client::ws_client::EventDispatcherHandler;
+use open_lark::openlark_client::ws_client::LarkWsClient;
+use tokio::sync::mpsc;
+use tokio::sync::watch;
+use tokio::time::Instant;
+use tokio::time::MissedTickBehavior;
+
+use super::provider_events_from_payload;
+use super::runtime_state;
+use crate::append_diagnostic_event;
+use crate::config::FeishuConfig;
+use crate::model::ConnectionStatus;
+use crate::provider::ProviderEvent;
+
+const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2);
+const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
+const IDLE_TIMEOUT: Duration = Duration::from_secs(60);
+const IDLE_WATCHDOG_POLL_INTERVAL: Duration = Duration::from_secs(1);
+
+pub(super) async fn run_with_reconnect(
+ workspace_root: PathBuf,
+ config: FeishuConfig,
+ provider_event_tx: mpsc::UnboundedSender,
+) -> Result<()> {
+ if !config.has_api_credentials() {
+ let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state(
+ ConnectionStatus::Unconfigured,
+ Some("missing app_id/app_secret".to_string()),
+ )?));
+ return Err(anyhow!("missing app_id/app_secret"));
+ }
+
+ let mut reconnect_delay = INITIAL_RECONNECT_DELAY;
+ loop {
+ match run_once(workspace_root.as_path(), &config, &provider_event_tx).await {
+ Ok(()) => {
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "feishu.runtime_disconnected",
+ serde_json::json!({
+ "reconnect_delay_secs": reconnect_delay.as_secs(),
+ }),
+ );
+ let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state(
+ ConnectionStatus::Disconnected,
+ Some(format!(
+ "Feishu websocket runtime exited; reconnecting in {}s",
+ reconnect_delay.as_secs()
+ )),
+ )?));
+ }
+ Err(error) => {
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "feishu.runtime_failed",
+ serde_json::json!({
+ "error": error.to_string(),
+ "reconnect_delay_secs": reconnect_delay.as_secs(),
+ }),
+ );
+ let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state(
+ ConnectionStatus::Error,
+ Some(format!(
+ "Feishu websocket runtime failed: {error}; reconnecting in {}s",
+ reconnect_delay.as_secs()
+ )),
+ )?));
+ }
+ }
+
+ tokio::time::sleep(reconnect_delay).await;
+ reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
+ }
+}
+
+async fn run_once(
+ workspace_root: &Path,
+ config: &FeishuConfig,
+ provider_event_tx: &mpsc::UnboundedSender,
+) -> Result<()> {
+ let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state(
+ ConnectionStatus::Connecting,
+ /*last_error*/ None,
+ )?));
+
+ let ws_config = Arc::new(build_websocket_config(config)?);
+ let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>();
+ let (last_payload_at_tx, last_payload_at_rx) = watch::channel(Instant::now());
+ let event_handler = EventDispatcherHandler::builder()
+ .payload_sender(payload_tx)
+ .build();
+ let payload_provider_event_tx = provider_event_tx.clone();
+ let payload_config = config.clone();
+ let payload_workspace_root = workspace_root.to_path_buf();
+ let payload_task = tokio::spawn(async move {
+ while let Some(payload) = payload_rx.recv().await {
+ let _ = last_payload_at_tx.send(Instant::now());
+ let _ = append_diagnostic_event(
+ payload_workspace_root.as_path(),
+ "feishu.raw_payload",
+ payload_debug_value(&payload),
+ );
+ for event in provider_events_from_payload(
+ &payload,
+ &payload_config,
+ payload_workspace_root.as_path(),
+ ) {
+ let _ = payload_provider_event_tx.send(event);
+ }
+ }
+ });
+ let last_payload_at_rx = last_payload_at_rx;
+
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.runtime_connected",
+ serde_json::json!({}),
+ );
+ let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state(
+ ConnectionStatus::Connected,
+ /*last_error*/ None,
+ )?));
+
+ let mut websocket_task =
+ tokio::spawn(async move { LarkWsClient::open(ws_config, event_handler).await });
+ let mut idle_watchdog = tokio::time::interval(IDLE_WATCHDOG_POLL_INTERVAL);
+ idle_watchdog.set_missed_tick_behavior(MissedTickBehavior::Delay);
+
+ loop {
+ tokio::select! {
+ websocket_result = &mut websocket_task => {
+ payload_task.abort();
+ return websocket_result
+ .map_err(|error| anyhow!("Feishu websocket runtime task failed: {error}"))?
+ .map_err(|error| anyhow!("Feishu websocket runtime failed: {error}"));
+ }
+ _ = idle_watchdog.tick() => {
+ let last_payload_at = *last_payload_at_rx.borrow();
+ if idle_timeout_exceeded(last_payload_at, Instant::now()) {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "feishu.runtime_idle_timeout",
+ serde_json::json!({
+ "idle_timeout_secs": IDLE_TIMEOUT.as_secs(),
+ }),
+ );
+ websocket_task.abort();
+ let _ = websocket_task.await;
+ payload_task.abort();
+ return Err(anyhow!(
+ "Feishu websocket idle timeout after {}s without payloads",
+ IDLE_TIMEOUT.as_secs()
+ ));
+ }
+ }
+ }
+ }
+}
+
+pub(super) fn build_websocket_config(config: &FeishuConfig) -> Result {
+ openlark_client::Config::builder()
+ .app_id(config.app_id.clone())
+ .app_secret(config.app_secret.clone())
+ .timeout(Duration::from_secs(30))
+ .build()
+ .map_err(|error| anyhow!("failed to build Feishu websocket config: {error}"))
+}
+
+fn payload_debug_value(payload: &[u8]) -> serde_json::Value {
+ serde_json::from_slice(payload).unwrap_or_else(|_| {
+ serde_json::json!({
+ "raw": String::from_utf8_lossy(payload),
+ })
+ })
+}
+
+fn idle_timeout_exceeded(last_payload_at: Instant, now: Instant) -> bool {
+ now.duration_since(last_payload_at) >= IDLE_TIMEOUT
+}
+
+#[cfg(test)]
+mod tests {
+ use super::IDLE_TIMEOUT;
+ use super::idle_timeout_exceeded;
+ use std::time::Duration;
+ use tokio::time::Instant;
+
+ #[test]
+ fn idle_timeout_triggers_at_threshold() {
+ let last_payload_at = Instant::now();
+
+ assert!(!idle_timeout_exceeded(
+ last_payload_at,
+ last_payload_at + IDLE_TIMEOUT - Duration::from_millis(1),
+ ));
+ assert!(idle_timeout_exceeded(
+ last_payload_at,
+ last_payload_at + IDLE_TIMEOUT,
+ ));
+ }
+}
diff --git a/codex-rs/clawbot/src/provider/feishu/sync.rs b/codex-rs/clawbot/src/provider/feishu/sync.rs
new file mode 100644
index 000000000..a8b6a0aff
--- /dev/null
+++ b/codex-rs/clawbot/src/provider/feishu/sync.rs
@@ -0,0 +1,255 @@
+use anyhow::Context;
+use anyhow::Result;
+use open_lark::openlark_communication::im::im::v1::chat::get::GetChatRequest;
+use open_lark::openlark_communication::im::im::v1::chat::list::ListChatsRequest;
+use open_lark::openlark_communication::im::im::v1::chat::models::ChatSortType;
+use open_lark::openlark_communication::im::im::v1::message::models::UserIdType;
+use serde::Deserialize;
+
+use crate::model::ProviderKind;
+use crate::model::ProviderSession;
+use crate::model::SessionStatus;
+
+pub(super) async fn discover_supported_sessions(
+ config: &open_lark::openlark_core::config::Config,
+) -> Result> {
+ let mut sessions = Vec::new();
+ let mut page_token = None;
+
+ loop {
+ let response = list_chat_page(config, page_token.clone()).await?;
+ let next_page_token = response.page_token.clone();
+
+ for chat in response.items {
+ if let Some(session) = load_supported_session(config, chat).await? {
+ sessions.push(session);
+ }
+ }
+
+ if !response.has_more {
+ break;
+ }
+
+ let Some(token) = next_page_token.filter(|token| !token.is_empty()) else {
+ break;
+ };
+ page_token = Some(token);
+ }
+
+ sessions.sort_by(|left, right| left.session_id.cmp(&right.session_id));
+ sessions.dedup_by(|left, right| left.session_id == right.session_id);
+ Ok(sessions)
+}
+
+async fn list_chat_page(
+ config: &open_lark::openlark_core::config::Config,
+ page_token: Option,
+) -> Result {
+ let mut request = ListChatsRequest::new(config.clone())
+ .user_id_type(UserIdType::OpenId)
+ .sort_type(ChatSortType::ByActiveTimeDesc)
+ .page_size(100);
+ if let Some(token) = page_token {
+ request = request.page_token(token);
+ }
+
+ let response = request
+ .execute()
+ .await
+ .context("failed to list Feishu chats")?;
+ serde_json::from_value(response).context("failed to parse Feishu chat list response")
+}
+
+async fn load_supported_session(
+ config: &open_lark::openlark_core::config::Config,
+ chat: FeishuChatListItem,
+) -> Result> {
+ let chat_id = chat.chat_id.clone();
+ let response = GetChatRequest::new(config.clone())
+ .chat_id(chat_id.clone())
+ .user_id_type(UserIdType::OpenId)
+ .execute()
+ .await
+ .with_context(|| format!("failed to load Feishu chat {chat_id}"))?;
+ let details: FeishuChatDetails = serde_json::from_value(response)
+ .with_context(|| format!("failed to parse Feishu chat details for {chat_id}"))?;
+
+ if !is_supported_chat(&details) {
+ return Ok(None);
+ }
+
+ Ok(Some(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: chat.chat_id,
+ display_name: first_non_empty([chat.name, details.name]),
+ unread_count: 0,
+ last_message_at: None,
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ }))
+}
+
+fn first_non_empty(values: [Option; 2]) -> Option {
+ values
+ .into_iter()
+ .flatten()
+ .map(|value| value.trim().to_string())
+ .find(|value| !value.is_empty())
+}
+
+fn is_supported_chat(details: &FeishuChatDetails) -> bool {
+ is_private_chat(details) || is_group_chat(details)
+}
+
+fn is_private_chat(details: &FeishuChatDetails) -> bool {
+ details.chat_type.as_deref() == Some("private")
+ || details.chat_mode.as_deref() == Some("p2p")
+ || details.r#type.as_deref() == Some("p2p")
+}
+
+fn is_group_chat(details: &FeishuChatDetails) -> bool {
+ details.chat_type.as_deref() == Some("group")
+ || details.chat_mode.as_deref() == Some("group")
+ || details.r#type.as_deref() == Some("group")
+}
+
+#[derive(Debug, Deserialize, PartialEq, Eq)]
+struct FeishuChatListResponse {
+ #[serde(default)]
+ items: Vec,
+ #[serde(default)]
+ page_token: Option,
+ #[serde(default)]
+ has_more: bool,
+}
+
+#[derive(Debug, Deserialize, PartialEq, Eq)]
+struct FeishuChatListItem {
+ chat_id: String,
+ name: Option,
+}
+
+#[derive(Debug, Deserialize, PartialEq, Eq)]
+struct FeishuChatDetails {
+ name: Option,
+ chat_mode: Option,
+ chat_type: Option,
+ #[serde(rename = "type")]
+ r#type: Option,
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+
+ use super::FeishuChatDetails;
+ use super::FeishuChatListItem;
+ use super::FeishuChatListResponse;
+ use super::first_non_empty;
+ use super::is_group_chat;
+ use super::is_private_chat;
+ use super::is_supported_chat;
+
+ #[test]
+ fn parse_list_chat_response_with_items() {
+ let response: FeishuChatListResponse = serde_json::from_value(serde_json::json!({
+ "items": [
+ { "chat_id": "oc_1", "name": "Alice" },
+ { "chat_id": "oc_2", "name": "Bob" }
+ ],
+ "page_token": "next_token",
+ "has_more": true
+ }))
+ .expect("response");
+
+ assert_eq!(
+ response,
+ FeishuChatListResponse {
+ items: vec![
+ FeishuChatListItem {
+ chat_id: "oc_1".to_string(),
+ name: Some("Alice".to_string()),
+ },
+ FeishuChatListItem {
+ chat_id: "oc_2".to_string(),
+ name: Some("Bob".to_string()),
+ },
+ ],
+ page_token: Some("next_token".to_string()),
+ has_more: true,
+ }
+ );
+ }
+
+ #[test]
+ fn parse_chat_details_with_type_field() {
+ let response: FeishuChatDetails = serde_json::from_value(serde_json::json!({
+ "chat_id": "oc_1",
+ "name": "Alice",
+ "type": "p2p"
+ }))
+ .expect("response");
+
+ assert_eq!(
+ response,
+ FeishuChatDetails {
+ name: Some("Alice".to_string()),
+ chat_mode: None,
+ chat_type: None,
+ r#type: Some("p2p".to_string()),
+ }
+ );
+ }
+
+ #[test]
+ fn private_chat_detection_accepts_private_variants() {
+ assert_eq!(
+ is_private_chat(&FeishuChatDetails {
+ name: Some("Alice".to_string()),
+ chat_mode: Some("group".to_string()),
+ chat_type: Some("private".to_string()),
+ r#type: None,
+ }),
+ true
+ );
+ assert_eq!(
+ is_private_chat(&FeishuChatDetails {
+ name: Some("Alice".to_string()),
+ chat_mode: Some("p2p".to_string()),
+ chat_type: Some("group".to_string()),
+ r#type: None,
+ }),
+ true
+ );
+ }
+
+ #[test]
+ fn group_chat_detection_accepts_group_variants() {
+ assert_eq!(
+ is_group_chat(&FeishuChatDetails {
+ name: Some("tracker".to_string()),
+ chat_mode: Some("group".to_string()),
+ chat_type: Some("group".to_string()),
+ r#type: None,
+ }),
+ true
+ );
+ assert_eq!(
+ is_supported_chat(&FeishuChatDetails {
+ name: Some("tracker".to_string()),
+ chat_mode: None,
+ chat_type: Some("group".to_string()),
+ r#type: None,
+ }),
+ true
+ );
+ }
+
+ #[test]
+ fn first_non_empty_skips_blank_values() {
+ assert_eq!(
+ first_non_empty([Some(" ".to_string()), Some("Alice".to_string())]),
+ Some("Alice".to_string())
+ );
+ }
+}
diff --git a/codex-rs/clawbot/src/provider/mod.rs b/codex-rs/clawbot/src/provider/mod.rs
new file mode 100644
index 000000000..a7304ff23
--- /dev/null
+++ b/codex-rs/clawbot/src/provider/mod.rs
@@ -0,0 +1,30 @@
+mod feishu;
+
+use crate::events::ProviderInboundMessage;
+use crate::model::ProviderMessageRef;
+use crate::model::ProviderRuntimeState;
+use crate::model::ProviderSession;
+use crate::model::ProviderSessionRef;
+
+pub use feishu::FeishuProviderRuntime;
+pub use feishu::failure_reply_text as feishu_failure_reply_text;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProviderOutboundTextMessage {
+ pub session: ProviderSessionRef,
+ pub text: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProviderOutboundReaction {
+ pub target: ProviderMessageRef,
+ pub emoji_type: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ProviderEvent {
+ RuntimeStateUpdated(ProviderRuntimeState),
+ SessionUpserted(ProviderSession),
+ SessionRemoved(ProviderSessionRef),
+ InboundMessage(ProviderInboundMessage),
+}
diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs
new file mode 100644
index 000000000..51abe4ea1
--- /dev/null
+++ b/codex-rs/clawbot/src/runtime.rs
@@ -0,0 +1,797 @@
+use std::collections::HashSet;
+use std::path::PathBuf;
+use std::time::SystemTime;
+use std::time::UNIX_EPOCH;
+
+use anyhow::Context;
+use anyhow::Result;
+
+use crate::config::ClawbotTurnMode;
+use crate::config::FeishuConfig;
+use crate::model::CachedUnreadMessage;
+use crate::model::ClawbotSnapshot;
+use crate::model::ForwardingDirection;
+use crate::model::ForwardingState;
+use crate::model::InboundMessageReceipt;
+use crate::model::ProviderKind;
+use crate::model::ProviderSession;
+use crate::model::ProviderSessionRef;
+use crate::model::SessionBinding;
+use crate::model::SessionStatus;
+use crate::provider::FeishuProviderRuntime;
+use crate::provider::ProviderEvent;
+use crate::store::ClawbotStore;
+
+#[derive(Debug)]
+pub struct ClawbotRuntime {
+ store: ClawbotStore,
+ snapshot: ClawbotSnapshot,
+}
+
+impl ClawbotRuntime {
+ pub fn load(workspace_root: PathBuf) -> Result {
+ let store = ClawbotStore::new(workspace_root);
+ let snapshot = store.load_snapshot()?;
+ Ok(Self { store, snapshot })
+ }
+
+ pub fn reload(&mut self) -> Result<&ClawbotSnapshot> {
+ self.snapshot = self.store.load_snapshot()?;
+ Ok(&self.snapshot)
+ }
+
+ pub fn snapshot(&self) -> &ClawbotSnapshot {
+ &self.snapshot
+ }
+
+ pub fn store(&self) -> &ClawbotStore {
+ &self.store
+ }
+
+ pub fn feishu_provider(&self) -> Option {
+ self.snapshot.config.feishu.clone().map(|config| {
+ FeishuProviderRuntime::new(self.store.workspace_root().to_path_buf(), config)
+ })
+ }
+
+ pub fn update_feishu_config(
+ &mut self,
+ feishu: Option,
+ ) -> Result<&ClawbotSnapshot> {
+ self.snapshot.config.feishu = feishu.filter(|config| !config.is_empty());
+ self.store.save_config(&self.snapshot.config)?;
+ self.reload()
+ }
+
+ pub fn update_turn_mode(&mut self, mode: ClawbotTurnMode) -> Result<&ClawbotSnapshot> {
+ self.snapshot.config.turn_mode = mode;
+ self.store.save_config(&self.snapshot.config)?;
+ self.reload()
+ }
+
+ pub async fn scan_feishu_sessions(&mut self) -> Result<&ClawbotSnapshot> {
+ let provider = self
+ .feishu_provider()
+ .context("Feishu credentials are not configured")?;
+ let discovered_sessions = provider
+ .scan_sessions()
+ .await?
+ .into_iter()
+ .filter_map(|event| match event {
+ ProviderEvent::SessionUpserted(session) => Some(session),
+ ProviderEvent::RuntimeStateUpdated(_)
+ | ProviderEvent::SessionRemoved(_)
+ | ProviderEvent::InboundMessage(_) => None,
+ })
+ .collect::>();
+ self.reconcile_feishu_sessions(discovered_sessions)
+ }
+
+ pub fn clear_unbound_feishu_sessions(&mut self) -> Result<&ClawbotSnapshot> {
+ let bound_sessions = self
+ .store
+ .load_bindings()?
+ .into_iter()
+ .map(|binding| binding.session_ref())
+ .collect::>();
+ let sessions = self
+ .store
+ .load_sessions()?
+ .into_iter()
+ .filter(|session| {
+ session.provider != ProviderKind::Feishu
+ || bound_sessions.contains(&session.session_ref())
+ })
+ .collect::>();
+ let unread_messages = self
+ .store
+ .load_unread_messages()?
+ .into_iter()
+ .filter(|message| {
+ message.provider != ProviderKind::Feishu
+ || bound_sessions.contains(&message.session_ref())
+ })
+ .collect::>();
+
+ self.store.save_sessions(&sessions)?;
+ self.store.save_unread_messages(&unread_messages)?;
+ self.reload()
+ }
+
+ pub fn persist_session(&mut self, session: ProviderSession) -> Result<&ClawbotSnapshot> {
+ self.store.upsert_session(session)?;
+ self.reload()
+ }
+
+ pub fn persist_binding(&mut self, binding: SessionBinding) -> Result<&ClawbotSnapshot> {
+ self.store.upsert_binding(binding)?;
+ self.reload()
+ }
+
+ pub fn connect_session_to_thread(
+ &mut self,
+ session: &ProviderSessionRef,
+ thread_id: String,
+ ) -> Result<&ClawbotSnapshot> {
+ let now = unix_timestamp_now()?;
+ let mut bindings = self.store.load_bindings()?;
+ let mut sessions = self.store.load_sessions()?;
+ let existing_binding = bindings
+ .iter()
+ .find(|binding| binding.session_ref() == *session)
+ .cloned();
+
+ for binding in &bindings {
+ if binding.thread_id == thread_id
+ && binding.session_ref() != *session
+ && let Some(existing_session) = sessions
+ .iter_mut()
+ .find(|existing| existing.session_ref() == binding.session_ref())
+ {
+ existing_session.bound_thread_id = None;
+ existing_session.status = SessionStatus::Discovered;
+ }
+ }
+
+ bindings
+ .retain(|binding| binding.thread_id != thread_id || binding.session_ref() == *session);
+ if let Some(binding) = bindings
+ .iter_mut()
+ .find(|binding| binding.session_ref() == *session)
+ {
+ binding.thread_id = thread_id.clone();
+ binding.updated_at = now;
+ } else {
+ bindings.push(SessionBinding {
+ provider: session.provider,
+ session_id: session.session_id.clone(),
+ thread_id: thread_id.clone(),
+ inbound_forwarding_enabled: true,
+ outbound_forwarding_enabled: true,
+ created_at: existing_binding
+ .as_ref()
+ .map_or(now, |binding| binding.created_at),
+ updated_at: now,
+ });
+ }
+
+ if let Some(provider_session) = sessions
+ .iter_mut()
+ .find(|provider_session| provider_session.session_ref() == *session)
+ {
+ provider_session.bound_thread_id = Some(thread_id.clone());
+ provider_session.status = SessionStatus::Bound;
+ } else {
+ sessions.push(ProviderSession {
+ provider: session.provider,
+ session_id: session.session_id.clone(),
+ display_name: None,
+ unread_count: self.unread_count_for_session(session)?,
+ last_message_at: None,
+ status: SessionStatus::Bound,
+ bound_thread_id: Some(thread_id),
+ });
+ }
+
+ self.store.save_bindings(&bindings)?;
+ self.store.save_sessions(&sessions)?;
+ self.reload()
+ }
+
+ pub fn load_binding_for_thread(&self, thread_id: &str) -> Result> {
+ Ok(self
+ .store
+ .load_bindings()?
+ .into_iter()
+ .find(|binding| binding.thread_id == thread_id))
+ }
+
+ pub fn load_binding_for_session(
+ &self,
+ session: &ProviderSessionRef,
+ ) -> Result > {
+ Ok(self
+ .store
+ .load_bindings()?
+ .into_iter()
+ .find(|binding| binding.session_ref() == *session))
+ }
+
+ pub fn bound_session_for_thread(&self, thread_id: &str) -> Result > {
+ Ok(self
+ .load_binding_for_thread(thread_id)?
+ .as_ref()
+ .map(SessionBinding::session_ref))
+ }
+
+ pub fn disconnect_thread(&mut self, thread_id: &str) -> Result > {
+ let Some(binding) = self.load_binding_for_thread(thread_id)? else {
+ return Ok(None);
+ };
+ let session = binding.session_ref();
+ let mut bindings = self.store.load_bindings()?;
+ bindings.retain(|candidate| candidate.thread_id != thread_id);
+ self.store.save_bindings(&bindings)?;
+
+ let mut sessions = self.store.load_sessions()?;
+ if let Some(existing) = sessions
+ .iter_mut()
+ .find(|candidate| candidate.session_ref() == session)
+ {
+ existing.bound_thread_id = None;
+ existing.status = SessionStatus::Discovered;
+ }
+ self.store.save_sessions(&sessions)?;
+ self.reload()?;
+ Ok(Some(session))
+ }
+
+ pub fn set_forwarding_state_for_thread(
+ &mut self,
+ thread_id: &str,
+ direction: ForwardingDirection,
+ state: ForwardingState,
+ ) -> Result > {
+ let mut bindings = self.store.load_bindings()?;
+ let Some(binding) = bindings
+ .iter_mut()
+ .find(|candidate| candidate.thread_id == thread_id)
+ else {
+ return Ok(None);
+ };
+ let next_enabled = matches!(state, ForwardingState::Enabled);
+ match direction {
+ ForwardingDirection::Inbound => binding.inbound_forwarding_enabled = next_enabled,
+ ForwardingDirection::Outbound => binding.outbound_forwarding_enabled = next_enabled,
+ }
+ binding.updated_at = unix_timestamp_now()?;
+ let updated = binding.clone();
+ self.store.save_bindings(&bindings)?;
+ self.reload()?;
+ Ok(Some(updated))
+ }
+
+ pub fn take_next_unread_message(
+ &mut self,
+ session: &ProviderSessionRef,
+ ) -> Result > {
+ let message = self.store.take_next_unread_message(session)?;
+ if message.is_some()
+ && let Some(mut provider_session) = self.load_session(session)?
+ {
+ provider_session.unread_count = provider_session.unread_count.saturating_sub(1);
+ self.store.upsert_session(provider_session)?;
+ }
+ self.reload()?;
+ Ok(message)
+ }
+
+ pub fn apply_provider_event(&mut self, event: ProviderEvent) -> Result<&ClawbotSnapshot> {
+ match event {
+ ProviderEvent::RuntimeStateUpdated(state) => {
+ self.store.upsert_runtime_state(state)?;
+ }
+ ProviderEvent::SessionUpserted(mut session) => {
+ session.bound_thread_id = self.lookup_bound_thread_id(&session.session_ref())?;
+ session.unread_count = self.unread_count_for_session(&session.session_ref())?;
+ if session.bound_thread_id.is_some() {
+ session.status = SessionStatus::Bound;
+ }
+ self.store.upsert_session(session)?;
+ }
+ ProviderEvent::SessionRemoved(session) => {
+ self.store.remove_session(&session)?;
+ }
+ ProviderEvent::InboundMessage(message) => {
+ if self
+ .store
+ .has_inbound_receipt(&message.session, &message.message_id)?
+ {
+ return self.reload();
+ }
+
+ self.store.append_unread_message(&CachedUnreadMessage {
+ provider: message.session.provider,
+ session_id: message.session.session_id.clone(),
+ message_id: message.message_id.clone(),
+ text: message.text,
+ received_at: message.received_at,
+ })?;
+ self.store.record_inbound_receipt(InboundMessageReceipt {
+ provider: message.session.provider,
+ session_id: message.session.session_id.clone(),
+ message_id: message.message_id,
+ received_at: message.received_at,
+ })?;
+
+ let mut session = self
+ .load_session(&message.session)?
+ .unwrap_or(ProviderSession {
+ provider: message.session.provider,
+ session_id: message.session.session_id.clone(),
+ display_name: None,
+ unread_count: 0,
+ last_message_at: None,
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ });
+ session.bound_thread_id = self.lookup_bound_thread_id(&message.session)?;
+ session.unread_count = self.unread_count_for_session(&message.session)?;
+ session.last_message_at = Some(message.received_at);
+ if session.bound_thread_id.is_some() {
+ session.status = SessionStatus::Bound;
+ }
+ self.store.upsert_session(session)?;
+ }
+ }
+ self.reload()
+ }
+
+ fn load_session(&self, session: &ProviderSessionRef) -> Result > {
+ Ok(self
+ .store
+ .load_sessions()?
+ .into_iter()
+ .find(|existing| existing.session_ref() == *session))
+ }
+
+ fn lookup_bound_thread_id(&self, session: &ProviderSessionRef) -> Result > {
+ Ok(self
+ .store
+ .load_bindings()?
+ .into_iter()
+ .find(|binding| binding.session_ref() == *session)
+ .map(|binding| binding.thread_id))
+ }
+
+ fn unread_count_for_session(&self, session: &ProviderSessionRef) -> Result {
+ Ok(self
+ .store
+ .load_unread_messages()?
+ .into_iter()
+ .filter(|message| message.session_ref() == *session)
+ .count())
+ }
+
+ fn reconcile_feishu_sessions(
+ &mut self,
+ discovered_sessions: Vec,
+ ) -> Result<&ClawbotSnapshot> {
+ let discovered_refs = discovered_sessions
+ .iter()
+ .map(ProviderSession::session_ref)
+ .collect::>();
+ let bindings = self
+ .store
+ .load_bindings()?
+ .into_iter()
+ .filter(|binding| {
+ binding.provider != ProviderKind::Feishu
+ || discovered_refs.contains(&binding.session_ref())
+ })
+ .collect::>();
+ let sessions = self
+ .store
+ .load_sessions()?
+ .into_iter()
+ .filter(|session| {
+ session.provider != ProviderKind::Feishu
+ || discovered_refs.contains(&session.session_ref())
+ })
+ .collect::>();
+ let unread_messages = self
+ .store
+ .load_unread_messages()?
+ .into_iter()
+ .filter(|message| {
+ message.provider != ProviderKind::Feishu
+ || discovered_refs.contains(&message.session_ref())
+ })
+ .collect::>();
+ self.store.save_bindings(&bindings)?;
+ self.store.save_sessions(&sessions)?;
+ self.store.save_unread_messages(&unread_messages)?;
+ for session in discovered_sessions {
+ self.apply_provider_event(ProviderEvent::SessionUpserted(session))?;
+ }
+ self.reload()
+ }
+}
+
+fn unix_timestamp_now() -> Result {
+ Ok(SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .context("system clock is before UNIX_EPOCH")?
+ .as_secs() as i64)
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+ use tempfile::tempdir;
+
+ use super::ClawbotRuntime;
+ use crate::config::ClawbotTurnMode;
+ use crate::config::FeishuConfig;
+ use crate::events::ProviderInboundMessage;
+ use crate::model::CachedUnreadMessage;
+ use crate::model::ConnectionStatus;
+ use crate::model::ForwardingDirection;
+ use crate::model::ForwardingState;
+ use crate::model::ProviderKind;
+ use crate::model::ProviderRuntimeState;
+ use crate::model::ProviderSession;
+ use crate::model::ProviderSessionRef;
+ use crate::model::SessionBinding;
+ use crate::model::SessionStatus;
+ use crate::provider::ProviderEvent;
+
+ #[test]
+ fn take_next_unread_message_is_fifo_per_session() {
+ let tempdir = tempdir().expect("tempdir");
+ let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime");
+ let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_1");
+
+ runtime
+ .persist_session(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_1".to_string(),
+ display_name: Some("Alice".to_string()),
+ unread_count: 0,
+ last_message_at: None,
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ })
+ .expect("session");
+ runtime
+ .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage {
+ session: session.clone(),
+ message_id: "msg_2".to_string(),
+ text: "second".to_string(),
+ received_at: 2,
+ }))
+ .expect("second");
+ runtime
+ .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage {
+ session: session.clone(),
+ message_id: "msg_1".to_string(),
+ text: "first".to_string(),
+ received_at: 1,
+ }))
+ .expect("first");
+
+ assert_eq!(
+ runtime
+ .take_next_unread_message(&session)
+ .expect("take first")
+ .expect("message")
+ .message_id,
+ "msg_1"
+ );
+ assert_eq!(
+ runtime
+ .take_next_unread_message(&session)
+ .expect("take second")
+ .expect("message")
+ .message_id,
+ "msg_2"
+ );
+ assert_eq!(
+ runtime
+ .take_next_unread_message(&session)
+ .expect("take none"),
+ None
+ );
+ }
+
+ #[test]
+ fn apply_provider_event_deduplicates_inbound_messages() {
+ let tempdir = tempdir().expect("tempdir");
+ let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime");
+ let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_2");
+
+ runtime
+ .apply_provider_event(ProviderEvent::RuntimeStateUpdated(ProviderRuntimeState {
+ provider: ProviderKind::Feishu,
+ connection: ConnectionStatus::Connected,
+ last_error: None,
+ updated_at: Some(1),
+ }))
+ .expect("runtime state");
+ runtime
+ .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage {
+ session: session.clone(),
+ message_id: "msg_1".to_string(),
+ text: "hello".to_string(),
+ received_at: 10,
+ }))
+ .expect("first inbound");
+ runtime
+ .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage {
+ session,
+ message_id: "msg_1".to_string(),
+ text: "hello".to_string(),
+ received_at: 10,
+ }))
+ .expect("duplicate inbound");
+
+ assert_eq!(runtime.snapshot().runtime.len(), 1);
+ assert_eq!(runtime.snapshot().unread_message_count, 1);
+ assert_eq!(runtime.snapshot().sessions.len(), 1);
+ assert_eq!(runtime.snapshot().sessions[0].unread_count, 1);
+ }
+
+ #[test]
+ fn update_feishu_config_clears_empty_values() {
+ let tempdir = tempdir().expect("tempdir");
+ let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime");
+
+ runtime
+ .update_feishu_config(Some(FeishuConfig {
+ app_id: "app".to_string(),
+ app_secret: "secret".to_string(),
+ verification_token: Some("token".to_string()),
+ encrypt_key: None,
+ bot_open_id: None,
+ bot_user_id: None,
+ }))
+ .expect("save config");
+ assert_eq!(
+ runtime.snapshot().config.feishu,
+ Some(FeishuConfig {
+ app_id: "app".to_string(),
+ app_secret: "secret".to_string(),
+ verification_token: Some("token".to_string()),
+ encrypt_key: None,
+ bot_open_id: None,
+ bot_user_id: None,
+ })
+ );
+
+ runtime
+ .update_feishu_config(Some(FeishuConfig::default()))
+ .expect("clear config");
+ assert_eq!(runtime.snapshot().config.feishu, None);
+ }
+
+ #[test]
+ fn update_turn_mode_persists_in_config() {
+ let tempdir = tempdir().expect("tempdir");
+ let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime");
+
+ runtime
+ .update_turn_mode(ClawbotTurnMode::NonInteractive)
+ .expect("save turn mode");
+
+ let reloaded = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("reload runtime");
+ assert_eq!(
+ reloaded.snapshot().config.turn_mode,
+ ClawbotTurnMode::NonInteractive
+ );
+ }
+
+ #[test]
+ fn disconnect_thread_clears_binding_and_session_state() {
+ let tempdir = tempdir().expect("tempdir");
+ let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime");
+ let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_3");
+
+ runtime
+ .persist_session(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_3".to_string(),
+ display_name: Some("Bob".to_string()),
+ unread_count: 0,
+ last_message_at: None,
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ })
+ .expect("session");
+ runtime
+ .connect_session_to_thread(&session, "thread_1".to_string())
+ .expect("bind session");
+
+ assert_eq!(
+ runtime.disconnect_thread("thread_1").expect("disconnect"),
+ Some(session.clone())
+ );
+ assert_eq!(
+ runtime
+ .bound_session_for_thread("thread_1")
+ .expect("bound session"),
+ None
+ );
+ assert_eq!(
+ runtime.snapshot().sessions,
+ vec![ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_3".to_string(),
+ display_name: Some("Bob".to_string()),
+ unread_count: 0,
+ last_message_at: None,
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ }]
+ );
+ }
+
+ #[test]
+ fn set_forwarding_state_for_thread_updates_binding_flags() {
+ let tempdir = tempdir().expect("tempdir");
+ let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime");
+ let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_4");
+
+ runtime
+ .connect_session_to_thread(&session, "thread_2".to_string())
+ .expect("bind session");
+
+ runtime
+ .set_forwarding_state_for_thread(
+ "thread_2",
+ ForwardingDirection::Inbound,
+ ForwardingState::Disabled,
+ )
+ .expect("disable inbound");
+ runtime
+ .set_forwarding_state_for_thread(
+ "thread_2",
+ ForwardingDirection::Outbound,
+ ForwardingState::Disabled,
+ )
+ .expect("disable outbound");
+
+ assert_eq!(
+ runtime.snapshot().bindings[0].clone(),
+ SessionBinding {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_4".to_string(),
+ thread_id: "thread_2".to_string(),
+ inbound_forwarding_enabled: false,
+ outbound_forwarding_enabled: false,
+ created_at: runtime.snapshot().bindings[0].created_at,
+ updated_at: runtime.snapshot().bindings[0].updated_at,
+ }
+ );
+ }
+
+ #[test]
+ fn clear_unbound_feishu_sessions_removes_unbound_sessions_and_unread_cache() {
+ let tempdir = tempdir().expect("tempdir");
+ let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime");
+ let bound_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_bound");
+ let unbound_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_unbound");
+
+ runtime
+ .connect_session_to_thread(&bound_session, "thread_3".to_string())
+ .expect("bind session");
+ runtime
+ .persist_session(ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_unbound".to_string(),
+ display_name: Some("Unbound".to_string()),
+ unread_count: 0,
+ last_message_at: None,
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ })
+ .expect("persist unbound session");
+ runtime
+ .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage {
+ session: bound_session.clone(),
+ message_id: "msg_bound".to_string(),
+ text: "bound".to_string(),
+ received_at: 1,
+ }))
+ .expect("bound unread");
+ runtime
+ .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage {
+ session: unbound_session,
+ message_id: "msg_unbound".to_string(),
+ text: "unbound".to_string(),
+ received_at: 2,
+ }))
+ .expect("unbound unread");
+
+ runtime
+ .clear_unbound_feishu_sessions()
+ .expect("clear unbound sessions");
+
+ assert_eq!(
+ runtime.snapshot().sessions,
+ vec![ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_bound".to_string(),
+ display_name: None,
+ unread_count: 1,
+ last_message_at: Some(1),
+ status: SessionStatus::Bound,
+ bound_thread_id: Some("thread_3".to_string()),
+ }]
+ );
+ assert_eq!(
+ runtime
+ .store()
+ .load_unread_messages()
+ .expect("unread messages"),
+ vec![CachedUnreadMessage {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_bound".to_string(),
+ message_id: "msg_bound".to_string(),
+ text: "bound".to_string(),
+ received_at: 1,
+ }]
+ );
+ }
+
+ #[test]
+ fn reconcile_feishu_sessions_prunes_missing_bound_sessions_and_unread_cache() {
+ let tempdir = tempdir().expect("tempdir");
+ let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime");
+ let stale_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_stale");
+ let live_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_live");
+
+ runtime
+ .connect_session_to_thread(&stale_session, "thread_stale".to_string())
+ .expect("bind stale session");
+ runtime
+ .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage {
+ session: stale_session,
+ message_id: "msg_stale".to_string(),
+ text: "stale".to_string(),
+ received_at: 1,
+ }))
+ .expect("stale unread");
+ runtime
+ .reconcile_feishu_sessions(vec![ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_live".to_string(),
+ display_name: Some("Live".to_string()),
+ unread_count: 0,
+ last_message_at: None,
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ }])
+ .expect("reconcile discovered sessions");
+
+ assert_eq!(runtime.snapshot().bindings, Vec::::new());
+ assert_eq!(
+ runtime.snapshot().sessions,
+ vec![ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: live_session.session_id,
+ display_name: Some("Live".to_string()),
+ unread_count: 0,
+ last_message_at: None,
+ status: SessionStatus::Discovered,
+ bound_thread_id: None,
+ }]
+ );
+ assert_eq!(
+ runtime
+ .store()
+ .load_unread_messages()
+ .expect("unread messages"),
+ Vec::::new()
+ );
+ }
+}
diff --git a/codex-rs/clawbot/src/store.rs b/codex-rs/clawbot/src/store.rs
new file mode 100644
index 000000000..e3ab419ea
--- /dev/null
+++ b/codex-rs/clawbot/src/store.rs
@@ -0,0 +1,410 @@
+use std::fs;
+use std::path::Path;
+use std::path::PathBuf;
+
+use anyhow::Context;
+use anyhow::Result;
+use serde::Serialize;
+use serde::de::DeserializeOwned;
+use toml::Value as TomlValue;
+
+use crate::config::ClawbotConfig;
+use crate::model::CLAWBOT_BINDINGS_RELATIVE_PATH;
+use crate::model::CLAWBOT_CONFIG_RELATIVE_PATH;
+use crate::model::CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH;
+use crate::model::CLAWBOT_PENDING_TURNS_RELATIVE_PATH;
+use crate::model::CLAWBOT_RELATIVE_DIR;
+use crate::model::CLAWBOT_RUNTIME_RELATIVE_PATH;
+use crate::model::CLAWBOT_SESSIONS_RELATIVE_PATH;
+use crate::model::CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH;
+use crate::model::CachedUnreadMessage;
+use crate::model::ClawbotSnapshot;
+use crate::model::InboundMessageReceipt;
+use crate::model::PendingClawbotTurn;
+use crate::model::ProviderKind;
+use crate::model::ProviderRuntimeState;
+use crate::model::ProviderSession;
+use crate::model::ProviderSessionRef;
+use crate::model::SessionBinding;
+
+const MAX_INBOUND_RECEIPTS: usize = 4_096;
+
+#[derive(Debug, Clone)]
+pub struct ClawbotStore {
+ workspace_root: PathBuf,
+}
+
+impl ClawbotStore {
+ pub fn new(workspace_root: impl Into) -> Self {
+ Self {
+ workspace_root: workspace_root.into(),
+ }
+ }
+
+ pub fn workspace_root(&self) -> &Path {
+ &self.workspace_root
+ }
+
+ pub fn root_dir(&self) -> PathBuf {
+ self.workspace_root.join(CLAWBOT_RELATIVE_DIR)
+ }
+
+ pub fn config_path(&self) -> PathBuf {
+ self.workspace_root.join(CLAWBOT_CONFIG_RELATIVE_PATH)
+ }
+
+ pub fn sessions_path(&self) -> PathBuf {
+ self.workspace_root.join(CLAWBOT_SESSIONS_RELATIVE_PATH)
+ }
+
+ pub fn bindings_path(&self) -> PathBuf {
+ self.workspace_root.join(CLAWBOT_BINDINGS_RELATIVE_PATH)
+ }
+
+ pub fn unread_messages_path(&self) -> PathBuf {
+ self.workspace_root
+ .join(CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH)
+ }
+
+ pub fn runtime_path(&self) -> PathBuf {
+ self.workspace_root.join(CLAWBOT_RUNTIME_RELATIVE_PATH)
+ }
+
+ pub fn pending_turns_path(&self) -> PathBuf {
+ self.workspace_root
+ .join(CLAWBOT_PENDING_TURNS_RELATIVE_PATH)
+ }
+
+ pub fn inbound_receipts_path(&self) -> PathBuf {
+ self.workspace_root
+ .join(CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH)
+ }
+
+ pub fn ensure_root_dir(&self) -> Result<()> {
+ fs::create_dir_all(self.root_dir())
+ .with_context(|| format!("failed to create {}", self.root_dir().display()))
+ }
+
+ pub fn load_snapshot(&self) -> Result {
+ let config = self.load_config()?;
+ let mut runtime = self.load_runtime_states()?;
+ if config.feishu.is_none()
+ && runtime
+ .iter()
+ .all(|state| state.provider != ProviderKind::Feishu)
+ {
+ runtime.push(ProviderRuntimeState::unconfigured(ProviderKind::Feishu));
+ }
+ let sessions = self.load_sessions()?;
+ let bindings = self.load_bindings()?;
+ let unread_message_count = self.load_unread_messages()?.len();
+ Ok(ClawbotSnapshot {
+ config,
+ runtime,
+ sessions,
+ bindings,
+ unread_message_count,
+ })
+ }
+
+ pub fn load_config(&self) -> Result {
+ let config_path = self.config_path();
+ if !config_path.exists() {
+ return Ok(ClawbotConfig::default());
+ }
+ let raw = fs::read_to_string(&config_path)
+ .with_context(|| format!("failed to read {}", config_path.display()))?;
+ toml::from_str(&raw).with_context(|| format!("failed to parse {}", config_path.display()))
+ }
+
+ pub fn save_config(&self, config: &ClawbotConfig) -> Result<()> {
+ let rendered = toml::to_string_pretty(config).context("failed to encode config")?;
+ let contents = if rendered.trim().is_empty() {
+ String::new()
+ } else {
+ let normalized = rendered
+ .parse::()
+ .ok()
+ .and_then(|value| toml::to_string_pretty(&value).ok())
+ .unwrap_or(rendered);
+ format!("{normalized}\n")
+ };
+ self.write_string_file(&self.config_path(), &contents)
+ }
+
+ pub fn load_runtime_states(&self) -> Result> {
+ read_optional_json_file(&self.runtime_path())
+ .with_context(|| format!("failed to load {}", self.runtime_path().display()))
+ }
+
+ pub fn save_runtime_states(&self, runtime_states: &[ProviderRuntimeState]) -> Result<()> {
+ let mut sorted = runtime_states.to_vec();
+ sorted.sort_by_key(|state| state.provider.title());
+ self.write_json_file(&self.runtime_path(), &sorted)
+ }
+
+ pub fn upsert_runtime_state(
+ &self,
+ runtime_state: ProviderRuntimeState,
+ ) -> Result> {
+ let mut runtime_states = self.load_runtime_states()?;
+ if let Some(existing) = runtime_states
+ .iter_mut()
+ .find(|state| state.provider == runtime_state.provider)
+ {
+ *existing = runtime_state;
+ } else {
+ runtime_states.push(runtime_state);
+ }
+ self.save_runtime_states(&runtime_states)?;
+ Ok(runtime_states)
+ }
+
+ pub fn load_sessions(&self) -> Result> {
+ read_optional_json_file(&self.sessions_path())
+ .with_context(|| format!("failed to load {}", self.sessions_path().display()))
+ }
+
+ pub fn save_sessions(&self, sessions: &[ProviderSession]) -> Result<()> {
+ let mut sorted = sessions.to_vec();
+ sorted.sort_by(|left, right| {
+ left.provider
+ .title()
+ .cmp(right.provider.title())
+ .then(left.session_id.cmp(&right.session_id))
+ });
+ self.write_json_file(&self.sessions_path(), &sorted)
+ }
+
+ pub fn upsert_session(&self, session: ProviderSession) -> Result> {
+ let mut sessions = self.load_sessions()?;
+ if let Some(existing) = sessions
+ .iter_mut()
+ .find(|existing| existing.session_ref() == session.session_ref())
+ {
+ *existing = session;
+ } else {
+ sessions.push(session);
+ }
+ self.save_sessions(&sessions)?;
+ Ok(sessions)
+ }
+
+ pub fn remove_session(&self, session: &ProviderSessionRef) -> Result> {
+ let mut sessions = self.load_sessions()?;
+ sessions.retain(|existing| existing.session_ref() != *session);
+ self.save_sessions(&sessions)?;
+ Ok(sessions)
+ }
+
+ pub fn load_bindings(&self) -> Result> {
+ read_optional_json_file(&self.bindings_path())
+ .with_context(|| format!("failed to load {}", self.bindings_path().display()))
+ }
+
+ pub fn save_bindings(&self, bindings: &[SessionBinding]) -> Result<()> {
+ let mut sorted = bindings.to_vec();
+ sorted.sort_by(|left, right| {
+ left.provider
+ .title()
+ .cmp(right.provider.title())
+ .then(left.session_id.cmp(&right.session_id))
+ .then(left.thread_id.cmp(&right.thread_id))
+ });
+ self.write_json_file(&self.bindings_path(), &sorted)
+ }
+
+ pub fn upsert_binding(&self, binding: SessionBinding) -> Result> {
+ let mut bindings = self.load_bindings()?;
+ if let Some(existing) = bindings
+ .iter_mut()
+ .find(|existing| existing.session_ref() == binding.session_ref())
+ {
+ *existing = binding;
+ } else {
+ bindings.push(binding);
+ }
+ self.save_bindings(&bindings)?;
+ Ok(bindings)
+ }
+
+ pub fn load_unread_messages(&self) -> Result> {
+ let unread_messages_path = self.unread_messages_path();
+ if !unread_messages_path.exists() {
+ return Ok(Vec::new());
+ }
+
+ let raw = fs::read_to_string(&unread_messages_path)
+ .with_context(|| format!("failed to read {}", unread_messages_path.display()))?;
+ raw.lines()
+ .filter(|line| !line.trim().is_empty())
+ .map(|line| {
+ serde_json::from_str::(line)
+ .with_context(|| format!("failed to parse {}", unread_messages_path.display()))
+ })
+ .collect()
+ }
+
+ pub fn save_unread_messages(&self, unread_messages: &[CachedUnreadMessage]) -> Result<()> {
+ let mut sorted = unread_messages.to_vec();
+ sorted.sort_by(|left, right| {
+ left.provider
+ .title()
+ .cmp(right.provider.title())
+ .then(left.session_id.cmp(&right.session_id))
+ .then(left.received_at.cmp(&right.received_at))
+ .then(left.message_id.cmp(&right.message_id))
+ });
+ sorted.dedup_by(|left, right| {
+ left.provider == right.provider
+ && left.session_id == right.session_id
+ && left.message_id == right.message_id
+ });
+
+ let rendered = if sorted.is_empty() {
+ String::new()
+ } else {
+ let lines = sorted
+ .iter()
+ .map(|message| serde_json::to_string(message).context("failed to encode unread"))
+ .collect::>>()?;
+ format!("{}\n", lines.join("\n"))
+ };
+ self.write_string_file(&self.unread_messages_path(), &rendered)
+ }
+
+ pub fn append_unread_message(&self, message: &CachedUnreadMessage) -> Result<()> {
+ let mut unread_messages = self.load_unread_messages()?;
+ unread_messages.push(message.clone());
+ self.save_unread_messages(&unread_messages)
+ }
+
+ pub fn take_next_unread_message(
+ &self,
+ session: &ProviderSessionRef,
+ ) -> Result> {
+ let mut unread_messages = self.load_unread_messages()?;
+ let Some(index) = unread_messages
+ .iter()
+ .enumerate()
+ .filter(|(_, message)| message.session_ref() == *session)
+ .min_by(|(_, left), (_, right)| {
+ left.received_at
+ .cmp(&right.received_at)
+ .then(left.message_id.cmp(&right.message_id))
+ })
+ .map(|(index, _)| index)
+ else {
+ return Ok(None);
+ };
+ let message = unread_messages.remove(index);
+ self.save_unread_messages(&unread_messages)?;
+ Ok(Some(message))
+ }
+
+ pub fn load_pending_turns(&self) -> Result> {
+ read_optional_json_file(&self.pending_turns_path())
+ .with_context(|| format!("failed to load {}", self.pending_turns_path().display()))
+ }
+
+ pub fn save_pending_turns(&self, pending_turns: &[PendingClawbotTurn]) -> Result<()> {
+ let mut sorted = pending_turns.to_vec();
+ sorted.sort_by(|left, right| {
+ left.thread_id
+ .cmp(&right.thread_id)
+ .then(left.turn_id.cmp(&right.turn_id))
+ .then(left.session.session_id.cmp(&right.session.session_id))
+ .then(left.message_id.cmp(&right.message_id))
+ });
+ sorted.dedup_by(|left, right| {
+ left.thread_id == right.thread_id && left.turn_id == right.turn_id
+ });
+ self.write_json_file(&self.pending_turns_path(), &sorted)
+ }
+
+ pub fn upsert_pending_turn(&self, pending_turn: PendingClawbotTurn) -> Result<()> {
+ let mut pending_turns = self.load_pending_turns()?;
+ pending_turns.retain(|existing| {
+ existing.thread_id != pending_turn.thread_id || existing.turn_id != pending_turn.turn_id
+ });
+ pending_turns.push(pending_turn);
+ self.save_pending_turns(&pending_turns)
+ }
+
+ pub fn remove_pending_turn(
+ &self,
+ thread_id: &str,
+ turn_id: &str,
+ ) -> Result> {
+ let mut pending_turns = self.load_pending_turns()?;
+ let Some(index) = pending_turns
+ .iter()
+ .position(|pending| pending.thread_id == thread_id && pending.turn_id == turn_id)
+ else {
+ return Ok(None);
+ };
+ let pending_turn = pending_turns.remove(index);
+ self.save_pending_turns(&pending_turns)?;
+ Ok(Some(pending_turn))
+ }
+
+ pub fn load_inbound_receipts(&self) -> Result> {
+ read_optional_json_file(&self.inbound_receipts_path())
+ .with_context(|| format!("failed to load {}", self.inbound_receipts_path().display()))
+ }
+
+ pub fn has_inbound_receipt(
+ &self,
+ session: &ProviderSessionRef,
+ message_id: &str,
+ ) -> Result {
+ Ok(self
+ .load_inbound_receipts()?
+ .into_iter()
+ .any(|receipt| receipt.session_ref() == *session && receipt.message_id == message_id))
+ }
+
+ pub fn record_inbound_receipt(&self, receipt: InboundMessageReceipt) -> Result<()> {
+ let mut receipts = self.load_inbound_receipts()?;
+ receipts.retain(|existing| {
+ existing.session_ref() != receipt.session_ref()
+ || existing.message_id != receipt.message_id
+ });
+ receipts.push(receipt);
+ receipts.sort_by(|left, right| {
+ left.received_at
+ .cmp(&right.received_at)
+ .then(left.session_id.cmp(&right.session_id))
+ .then(left.message_id.cmp(&right.message_id))
+ });
+ if receipts.len() > MAX_INBOUND_RECEIPTS {
+ receipts.drain(..receipts.len() - MAX_INBOUND_RECEIPTS);
+ }
+ self.write_json_file(&self.inbound_receipts_path(), &receipts)
+ }
+
+ fn write_json_file(&self, path: &Path, value: &T) -> Result<()>
+ where
+ T: Serialize,
+ {
+ let rendered = serde_json::to_string_pretty(value).context("failed to encode json")?;
+ self.write_string_file(path, &format!("{rendered}\n"))
+ }
+
+ fn write_string_file(&self, path: &Path, contents: &str) -> Result<()> {
+ self.ensure_root_dir()?;
+ fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
+ }
+}
+
+fn read_optional_json_file(path: &Path) -> Result>
+where
+ T: DeserializeOwned,
+{
+ if !path.exists() {
+ return Ok(Vec::new());
+ }
+ let raw =
+ fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
+ serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
+}
diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs
index 1c4f5068f..31250ef9e 100644
--- a/codex-rs/cli/src/main.rs
+++ b/codex-rs/cli/src/main.rs
@@ -30,6 +30,7 @@ use codex_tui::ExitReason;
use codex_tui::update_action::UpdateAction;
use codex_utils_cli::CliConfigOverrides;
use owo_colors::OwoColorize;
+use std::ffi::OsString;
use std::io::IsTerminal;
use std::path::PathBuf;
use supports_color::Stream;
@@ -461,6 +462,28 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec anyhow::Result<()> {
+ if matches!(exit_info.exit_reason, ExitReason::RespawnRequested) {
+ let Some(thread_id) = exit_info.thread_id.as_ref() else {
+ anyhow::bail!("cannot respawn Codex: current session has no thread id");
+ };
+ respawn_current_codex_session(
+ arg0_paths,
+ respawn_args,
+ &thread_id.to_string(),
+ exit_info.respawn_with_yolo,
+ )?;
+ return Ok(());
+ }
+
+ handle_app_exit(exit_info)
+}
+
/// Handle the app exit and print the results. Optionally run the update action.
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
match exit_info.exit_reason {
@@ -468,7 +491,7 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
eprintln!("ERROR: {message}");
std::process::exit(1);
}
- ExitReason::UserRequested => { /* normal exit */ }
+ ExitReason::UserRequested | ExitReason::RespawnRequested => { /* normal exit */ }
}
let update_action = exit_info.update_action;
@@ -482,6 +505,167 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
Ok(())
}
+fn respawn_current_codex_session(
+ arg0_paths: &Arg0DispatchPaths,
+ respawn_args: &[OsString],
+ thread_id: &str,
+ respawn_with_yolo: bool,
+) -> anyhow::Result<()> {
+ let Some(exe_path) = arg0_paths.codex_self_exe.as_ref() else {
+ anyhow::bail!("unable to respawn Codex: current executable path is unavailable");
+ };
+
+ let mut command = std::process::Command::new(exe_path);
+ command.args(build_codex_respawn_argv(
+ respawn_args,
+ thread_id,
+ respawn_with_yolo,
+ ));
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::process::CommandExt;
+
+ let error = command.exec();
+ anyhow::bail!(
+ "failed to respawn Codex via {}: {error}",
+ exe_path.display()
+ );
+ }
+
+ #[cfg(not(unix))]
+ {
+ command
+ .stdin(std::process::Stdio::inherit())
+ .stdout(std::process::Stdio::inherit())
+ .stderr(std::process::Stdio::inherit());
+ command.spawn().map_err(|error| {
+ anyhow::anyhow!(
+ "failed to respawn Codex via {}: {error}",
+ exe_path.display()
+ )
+ })?;
+ Ok(())
+ }
+}
+
+fn build_codex_respawn_argv(
+ respawn_args: &[OsString],
+ thread_id: &str,
+ respawn_with_yolo: bool,
+) -> Vec {
+ let mut args = normalize_respawn_mode_args(respawn_args, respawn_with_yolo);
+ args.push("resume".into());
+ args.push(thread_id.into());
+ if respawn_with_yolo {
+ args.push("--yolo".into());
+ }
+ args
+}
+
+fn normalize_respawn_mode_args(args: &[OsString], respawn_with_yolo: bool) -> Vec {
+ let mut normalized = Vec::new();
+ let mut iter = args.iter();
+ while let Some(arg) = iter.next() {
+ let Some(arg_str) = arg.to_str() else {
+ normalized.push(arg.clone());
+ continue;
+ };
+ match arg_str {
+ "--yolo" | "--dangerously-bypass-approvals-and-sandbox" => {}
+ "--ask-for-approval" | "--sandbox" if respawn_with_yolo => {
+ let _ = iter.next();
+ }
+ "--full-auto" if respawn_with_yolo => {}
+ _ => normalized.push(arg.clone()),
+ }
+ }
+ normalized
+}
+
+fn approval_mode_cli_arg_name(value: codex_utils_cli::ApprovalModeCliArg) -> &'static str {
+ match value {
+ codex_utils_cli::ApprovalModeCliArg::Untrusted => "untrusted",
+ codex_utils_cli::ApprovalModeCliArg::OnFailure => "on-failure",
+ codex_utils_cli::ApprovalModeCliArg::OnRequest => "on-request",
+ codex_utils_cli::ApprovalModeCliArg::Never => "never",
+ }
+}
+
+fn sandbox_mode_cli_arg_name(value: codex_utils_cli::SandboxModeCliArg) -> &'static str {
+ match value {
+ codex_utils_cli::SandboxModeCliArg::ReadOnly => "read-only",
+ codex_utils_cli::SandboxModeCliArg::WorkspaceWrite => "workspace-write",
+ codex_utils_cli::SandboxModeCliArg::DangerFullAccess => "danger-full-access",
+ }
+}
+
+fn push_arg_value(args: &mut Vec, flag: &'static str, value: impl Into) {
+ args.push(flag.into());
+ args.push(value.into());
+}
+
+fn extend_tui_cli_respawn_args(args: &mut Vec, interactive: &TuiCli) {
+ if let Some(model) = &interactive.model {
+ push_arg_value(args, "--model", model.clone());
+ }
+ if interactive.oss {
+ args.push("--oss".into());
+ }
+ if let Some(provider) = &interactive.oss_provider {
+ push_arg_value(args, "--local-provider", provider.clone());
+ }
+ if let Some(profile) = &interactive.config_profile {
+ push_arg_value(args, "--profile", profile.clone());
+ }
+ if let Some(sandbox_mode) = interactive.sandbox_mode {
+ push_arg_value(args, "--sandbox", sandbox_mode_cli_arg_name(sandbox_mode));
+ }
+ if let Some(approval_policy) = interactive.approval_policy {
+ push_arg_value(
+ args,
+ "--ask-for-approval",
+ approval_mode_cli_arg_name(approval_policy),
+ );
+ }
+ if interactive.full_auto {
+ args.push("--full-auto".into());
+ }
+ if interactive.dangerously_bypass_approvals_and_sandbox {
+ args.push("--yolo".into());
+ }
+ if let Some(cwd) = &interactive.cwd {
+ push_arg_value(args, "--cd", cwd.as_os_str().to_os_string());
+ }
+ if interactive.web_search {
+ args.push("--search".into());
+ }
+ for dir in &interactive.add_dir {
+ push_arg_value(args, "--add-dir", dir.as_os_str().to_os_string());
+ }
+ if interactive.no_alt_screen {
+ args.push("--no-alt-screen".into());
+ }
+ for raw_override in &interactive.config_overrides.raw_overrides {
+ push_arg_value(args, "-c", raw_override.clone());
+ }
+}
+
+fn build_interactive_respawn_args(
+ remote: &InteractiveRemoteOptions,
+ interactive: &TuiCli,
+) -> Vec {
+ let mut args = Vec::new();
+ if let Some(remote_addr) = &remote.remote {
+ push_arg_value(&mut args, "--remote", remote_addr.clone());
+ }
+ if let Some(env_var) = &remote.remote_auth_token_env {
+ push_arg_value(&mut args, "--remote-auth-token-env", env_var.clone());
+ }
+ extend_tui_cli_respawn_args(&mut args, interactive);
+ args
+}
+
/// Run the update action and print the result.
fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
println!();
@@ -638,6 +822,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
&mut interactive.config_overrides,
root_config_overrides.clone(),
);
+ let respawn_args = build_interactive_respawn_args(
+ &InteractiveRemoteOptions {
+ remote: root_remote.clone(),
+ remote_auth_token_env: root_remote_auth_token_env.clone(),
+ },
+ &interactive,
+ );
let exit_info = run_interactive_tui(
interactive,
root_remote.clone(),
@@ -645,7 +836,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
arg0_paths.clone(),
)
.await?;
- handle_app_exit(exit_info)?;
+ finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?;
}
Some(Subcommand::Exec(mut exec_cli)) => {
reject_remote_mode_for_subcommand(
@@ -766,16 +957,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
include_non_interactive,
config_overrides,
);
- let exit_info = run_interactive_tui(
- interactive,
- remote.remote.or(root_remote.clone()),
- remote
+ let effective_remote = InteractiveRemoteOptions {
+ remote: remote.remote.or(root_remote.clone()),
+ remote_auth_token_env: remote
.remote_auth_token_env
.or(root_remote_auth_token_env.clone()),
+ };
+ let respawn_args = build_interactive_respawn_args(&effective_remote, &interactive);
+ let exit_info = run_interactive_tui(
+ interactive,
+ effective_remote.remote,
+ effective_remote.remote_auth_token_env,
arg0_paths.clone(),
)
.await?;
- handle_app_exit(exit_info)?;
+ finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?;
}
Some(Subcommand::Fork(ForkCommand {
session_id,
@@ -792,16 +988,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
all,
config_overrides,
);
- let exit_info = run_interactive_tui(
- interactive,
- remote.remote.or(root_remote.clone()),
- remote
+ let effective_remote = InteractiveRemoteOptions {
+ remote: remote.remote.or(root_remote.clone()),
+ remote_auth_token_env: remote
.remote_auth_token_env
.or(root_remote_auth_token_env.clone()),
+ };
+ let respawn_args = build_interactive_respawn_args(&effective_remote, &interactive);
+ let exit_info = run_interactive_tui(
+ interactive,
+ effective_remote.remote,
+ effective_remote.remote_auth_token_env,
arg0_paths.clone(),
)
.await?;
- handle_app_exit(exit_info)?;
+ finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?;
}
Some(Subcommand::Login(mut login_cli)) => {
reject_remote_mode_for_subcommand(
@@ -1474,6 +1675,12 @@ mod tests {
use codex_protocol::protocol::TokenUsage;
use pretty_assertions::assert_eq;
+ fn lossy_args(args: &[OsString]) -> Vec {
+ args.iter()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect()
+ }
+
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let MultitoolCli {
@@ -1624,6 +1831,7 @@ mod tests {
.map(Result::unwrap),
thread_name: thread_name.map(str::to_string),
update_action: None,
+ respawn_with_yolo: false,
exit_reason: ExitReason::UserRequested,
}
}
@@ -1635,12 +1843,95 @@ mod tests {
thread_id: None,
thread_name: None,
update_action: None,
+ respawn_with_yolo: false,
exit_reason: ExitReason::UserRequested,
};
let lines = format_exit_messages(exit_info, /*color_enabled*/ false);
assert!(lines.is_empty());
}
+ #[test]
+ fn build_interactive_respawn_args_preserves_effective_session_args() {
+ let cli = MultitoolCli::try_parse_from([
+ "codex",
+ "--remote",
+ "ws://127.0.0.1:4500",
+ "--remote-auth-token-env",
+ "CODEX_AUTH",
+ "--model",
+ "gpt-5",
+ "--profile",
+ "work",
+ "--cd",
+ "/tmp/project",
+ "--search",
+ "--add-dir",
+ "/tmp/extra",
+ "--no-alt-screen",
+ "-c",
+ "foo=1",
+ ])
+ .expect("parse");
+ let MultitoolCli {
+ mut interactive,
+ config_overrides: root_overrides,
+ remote,
+ ..
+ } = cli;
+ prepend_config_flags(&mut interactive.config_overrides, root_overrides);
+
+ let args = build_interactive_respawn_args(&remote, &interactive);
+
+ assert_eq!(
+ lossy_args(&args),
+ vec![
+ "--remote",
+ "ws://127.0.0.1:4500",
+ "--remote-auth-token-env",
+ "CODEX_AUTH",
+ "--model",
+ "gpt-5",
+ "--profile",
+ "work",
+ "--cd",
+ "/tmp/project",
+ "--search",
+ "--add-dir",
+ "/tmp/extra",
+ "--no-alt-screen",
+ "-c",
+ "foo=1",
+ ]
+ );
+ }
+
+ #[test]
+ fn build_codex_respawn_argv_rewrites_mode_flags_for_yolo_respawn() {
+ let respawn_args = vec![
+ OsString::from("--remote"),
+ OsString::from("ws://127.0.0.1:4500"),
+ OsString::from("--sandbox"),
+ OsString::from("workspace-write"),
+ OsString::from("--ask-for-approval"),
+ OsString::from("on-request"),
+ OsString::from("--full-auto"),
+ ];
+
+ let argv =
+ build_codex_respawn_argv(&respawn_args, "thread-123", /*respawn_with_yolo*/ true);
+
+ assert_eq!(
+ lossy_args(&argv),
+ vec![
+ "--remote",
+ "ws://127.0.0.1:4500",
+ "resume",
+ "thread-123",
+ "--yolo",
+ ]
+ );
+ }
+
#[test]
fn format_exit_messages_includes_resume_hint_without_color() {
let exit_info = sample_exit_info(
diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs
index c52383352..e7441084f 100644
--- a/codex-rs/config/src/types.rs
+++ b/codex-rs/config/src/types.rs
@@ -450,6 +450,29 @@ pub struct ModelAvailabilityNuxConfig {
pub shown_count: HashMap,
}
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
+#[schemars(deny_unknown_fields)]
+pub struct TuiDisplayPreferences {
+ /// Show MCP/custom tool result bodies in transcript cells.
+ /// Defaults to `true`.
+ #[serde(default = "default_true")]
+ pub show_tool_results: bool,
+
+ /// Show patch/edit diff summaries in transcript cells.
+ /// Defaults to `true`.
+ #[serde(default = "default_true")]
+ pub show_patch_diffs: bool,
+}
+
+impl Default for TuiDisplayPreferences {
+ fn default() -> Self {
+ Self {
+ show_tool_results: true,
+ show_patch_diffs: true,
+ }
+ }
+}
+
/// Collection of settings that are specific to the TUI.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -510,6 +533,10 @@ pub struct Tui {
/// Startup tooltip availability NUX state persisted by the TUI.
#[serde(default)]
pub model_availability_nux: ModelAvailabilityNuxConfig,
+
+ /// Transcript visibility preferences that affect only local TUI rendering.
+ #[serde(default)]
+ pub display_preferences: TuiDisplayPreferences,
}
const fn default_true() -> bool {
diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json
index 7f5be535e..696d6fe45 100644
--- a/codex-rs/core/config.schema.json
+++ b/codex-rs/core/config.schema.json
@@ -1735,6 +1735,18 @@
"description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.",
"type": "boolean"
},
+ "display_preferences": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/TuiDisplayPreferences"
+ }
+ ],
+ "default": {
+ "show_patch_diffs": true,
+ "show_tool_results": true
+ },
+ "description": "Transcript visibility preferences that affect only local TUI rendering."
+ },
"model_availability_nux": {
"allOf": [
{
@@ -1791,6 +1803,22 @@
},
"type": "object"
},
+ "TuiDisplayPreferences": {
+ "additionalProperties": false,
+ "properties": {
+ "show_patch_diffs": {
+ "default": true,
+ "description": "Show patch/edit diff summaries in transcript cells. Defaults to `true`.",
+ "type": "boolean"
+ },
+ "show_tool_results": {
+ "default": true,
+ "description": "Show MCP/custom tool result bodies in transcript cells. Defaults to `true`.",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
"UriBasedFileOpener": {
"oneOf": [
{
diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs
index 384458a69..87f43d02d 100644
--- a/codex-rs/core/src/config/config_tests.rs
+++ b/codex-rs/core/src/config/config_tests.rs
@@ -18,6 +18,7 @@ use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::NotificationMethod;
use codex_config::types::Notifications;
use codex_config::types::ToolSuggestDiscoverableType;
+use codex_config::types::TuiDisplayPreferences;
use codex_features::Feature;
use codex_features::FeaturesToml;
use codex_model_provider_info::WireApi;
@@ -293,6 +294,7 @@ fn config_toml_deserializes_model_availability_nux() {
("gpt-foo".to_string(), 2),
]),
},
+ display_preferences: TuiDisplayPreferences::default(),
}
);
}
@@ -310,6 +312,31 @@ fn runtime_config_defaults_model_availability_nux() {
cfg.model_availability_nux,
ModelAvailabilityNuxConfig::default()
);
+ assert_eq!(
+ cfg.tui_display_preferences,
+ TuiDisplayPreferences::default()
+ );
+}
+
+#[test]
+fn config_toml_deserializes_tui_display_preferences() {
+ let toml = r#"
+[tui.display_preferences]
+show_tool_results = false
+show_patch_diffs = false
+"#;
+ let cfg: ConfigToml =
+ toml::from_str(toml).expect("TOML deserialization should succeed for TUI display prefs");
+
+ assert_eq!(
+ cfg.tui
+ .expect("tui config should deserialize")
+ .display_preferences,
+ TuiDisplayPreferences {
+ show_tool_results: false,
+ show_patch_diffs: false,
+ }
+ );
}
#[test]
@@ -985,6 +1012,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
terminal_title: None,
theme: None,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
+ display_preferences: TuiDisplayPreferences::default(),
}
);
}
@@ -4519,6 +4547,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
animations: true,
show_tooltips: true,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
+ tui_display_preferences: TuiDisplayPreferences::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
tool_suggest: ToolSuggestConfig::default(),
@@ -4664,6 +4693,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
animations: true,
show_tooltips: true,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
+ tui_display_preferences: TuiDisplayPreferences::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
tool_suggest: ToolSuggestConfig::default(),
@@ -4807,6 +4837,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
animations: true,
show_tooltips: true,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
+ tui_display_preferences: TuiDisplayPreferences::default(),
analytics_enabled: Some(false),
feedback_enabled: true,
tool_suggest: ToolSuggestConfig::default(),
@@ -4936,6 +4967,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
animations: true,
show_tooltips: true,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
+ tui_display_preferences: TuiDisplayPreferences::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
tool_suggest: ToolSuggestConfig::default(),
diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs
index 8eb069133..d0f72ece6 100644
--- a/codex-rs/core/src/config/mod.rs
+++ b/codex-rs/core/src/config/mod.rs
@@ -48,6 +48,7 @@ use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDiscoverable;
use codex_config::types::Tui;
+use codex_config::types::TuiDisplayPreferences;
use codex_config::types::UriBasedFileOpener;
use codex_config::types::WindowsSandboxModeToml;
use codex_config::types::WindowsToml;
@@ -337,6 +338,9 @@ pub struct Config {
/// Persisted startup availability NUX state for model tooltips.
pub model_availability_nux: ModelAvailabilityNuxConfig,
+ /// Transcript visibility preferences that affect only local TUI rendering.
+ pub tui_display_preferences: TuiDisplayPreferences,
+
/// Start the TUI in the specified collaboration mode (plan/default).
/// Controls whether the TUI uses the terminal's alternate screen buffer.
@@ -2780,6 +2784,11 @@ impl Config {
.as_ref()
.map(|t| t.model_availability_nux.clone())
.unwrap_or_default(),
+ tui_display_preferences: cfg
+ .tui
+ .as_ref()
+ .map(|t| t.display_preferences.clone())
+ .unwrap_or_default(),
tui_alternate_screen: cfg
.tui
.as_ref()
diff --git a/codex-rs/core/src/tools/handlers/grep_files.rs b/codex-rs/core/src/tools/handlers/grep_files.rs
new file mode 100644
index 000000000..ddc61d437
--- /dev/null
+++ b/codex-rs/core/src/tools/handlers/grep_files.rs
@@ -0,0 +1,159 @@
+use crate::function_tool::FunctionCallError;
+use crate::tools::context::FunctionToolOutput;
+use crate::tools::context::ToolInvocation;
+use crate::tools::context::ToolPayload;
+use crate::tools::handlers::parse_arguments;
+use crate::tools::registry::ToolHandler;
+use crate::tools::registry::ToolKind;
+use serde::Deserialize;
+use std::path::Path;
+use std::path::PathBuf;
+use std::process::Stdio;
+use std::time::SystemTime;
+use tokio::process::Command;
+
+pub struct GrepFilesHandler;
+
+const DEFAULT_LIMIT: usize = 100;
+
+fn default_limit() -> usize {
+ DEFAULT_LIMIT
+}
+
+#[derive(Debug, Deserialize)]
+struct GrepFilesArgs {
+ pattern: String,
+ #[serde(default)]
+ include: Option,
+ #[serde(default)]
+ path: Option,
+ #[serde(default = "default_limit")]
+ limit: usize,
+}
+
+impl ToolHandler for GrepFilesHandler {
+ type Output = FunctionToolOutput;
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Function
+ }
+
+ async fn handle(&self, invocation: ToolInvocation) -> Result {
+ let ToolInvocation { payload, turn, .. } = invocation;
+ let arguments = match payload {
+ ToolPayload::Function { arguments } => arguments,
+ _ => {
+ return Err(FunctionCallError::RespondToModel(
+ "grep_files handler received unsupported payload".to_string(),
+ ));
+ }
+ };
+
+ let args: GrepFilesArgs = parse_arguments(&arguments)?;
+ if args.limit == 0 {
+ return Err(FunctionCallError::RespondToModel(
+ "limit must be greater than zero".to_string(),
+ ));
+ }
+
+ let search_root = args
+ .path
+ .map(PathBuf::from)
+ .map(|path| crate::util::resolve_path(turn.cwd.as_path(), &path))
+ .unwrap_or_else(|| turn.cwd.to_path_buf());
+
+ let matches = run_rg_search(
+ &args.pattern,
+ args.include.as_deref(),
+ search_root.as_path(),
+ args.limit,
+ turn.cwd.as_path(),
+ )
+ .await?;
+
+ let output = if matches.is_empty() {
+ "No matching files found".to_string()
+ } else {
+ matches.join("\n")
+ };
+ Ok(FunctionToolOutput::from_text(output, Some(true)))
+ }
+}
+
+async fn run_rg_search(
+ pattern: &str,
+ include: Option<&str>,
+ path: &Path,
+ limit: usize,
+ cwd: &Path,
+) -> Result, FunctionCallError> {
+ let mut command = Command::new("rg");
+ command
+ .arg("--files-with-matches")
+ .arg("--no-messages")
+ .arg("--color")
+ .arg("never")
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .current_dir(cwd);
+ if let Some(include) = include {
+ command.arg("--glob").arg(include);
+ }
+ command.arg(pattern).arg(path);
+
+ let output = command
+ .output()
+ .await
+ .map_err(|err| FunctionCallError::RespondToModel(format!("failed to run rg: {err}")))?;
+
+ match output.status.code() {
+ Some(0) => {
+ let mut results = parse_results(&output.stdout, usize::MAX)
+ .into_iter()
+ .map(|result| crate::util::resolve_path(cwd, &PathBuf::from(result)))
+ .collect::>();
+ results.sort_by(|left, right| {
+ let left_modified = modified_time(left.as_path());
+ let right_modified = modified_time(right.as_path());
+ right_modified
+ .cmp(&left_modified)
+ .then_with(|| left.cmp(right))
+ });
+ results.truncate(limit);
+ Ok(results
+ .into_iter()
+ .map(|path| path.display().to_string())
+ .collect())
+ }
+ Some(1) => Ok(Vec::new()),
+ _ => {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ let details = if stderr.is_empty() {
+ format!("rg exited with status {}", output.status)
+ } else {
+ stderr
+ };
+ Err(FunctionCallError::RespondToModel(format!(
+ "grep_files search failed: {details}"
+ )))
+ }
+ }
+}
+
+fn modified_time(path: &Path) -> SystemTime {
+ std::fs::metadata(path)
+ .and_then(|metadata| metadata.modified())
+ .unwrap_or(SystemTime::UNIX_EPOCH)
+}
+
+fn parse_results(stdout: &[u8], limit: usize) -> Vec {
+ String::from_utf8_lossy(stdout)
+ .lines()
+ .take(limit)
+ .map(str::to_string)
+ .collect()
+}
+
+#[cfg(test)]
+#[path = "grep_files_tests.rs"]
+mod tests;
diff --git a/codex-rs/core/src/tools/handlers/grep_files_tests.rs b/codex-rs/core/src/tools/handlers/grep_files_tests.rs
index 0cc247c6f..3d3348616 100644
--- a/codex-rs/core/src/tools/handlers/grep_files_tests.rs
+++ b/codex-rs/core/src/tools/handlers/grep_files_tests.rs
@@ -5,7 +5,7 @@ use tempfile::tempdir;
#[test]
fn parses_basic_results() {
let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n";
- let parsed = parse_results(stdout, 10);
+ let parsed = parse_results(stdout, /*limit*/ 10);
assert_eq!(
parsed,
vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()]
@@ -15,7 +15,7 @@ fn parses_basic_results() {
#[test]
fn parse_truncates_after_limit() {
let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n/tmp/file_c.rs\n";
- let parsed = parse_results(stdout, 2);
+ let parsed = parse_results(stdout, /*limit*/ 2);
assert_eq!(
parsed,
vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()]
@@ -33,7 +33,7 @@ async fn run_search_returns_results() -> anyhow::Result<()> {
std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap();
std::fs::write(dir.join("other.txt"), "omega").unwrap();
- let results = run_rg_search("alpha", None, dir, 10, dir).await?;
+ let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 10, dir).await?;
assert_eq!(results.len(), 2);
assert!(results.iter().any(|path| path.ends_with("match_one.txt")));
assert!(results.iter().any(|path| path.ends_with("match_two.txt")));
@@ -50,7 +50,7 @@ async fn run_search_with_glob_filter() -> anyhow::Result<()> {
std::fs::write(dir.join("match_one.rs"), "alpha beta gamma").unwrap();
std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap();
- let results = run_rg_search("alpha", Some("*.rs"), dir, 10, dir).await?;
+ let results = run_rg_search("alpha", Some("*.rs"), dir, /*limit*/ 10, dir).await?;
assert_eq!(results.len(), 1);
assert!(results.iter().all(|path| path.ends_with("match_one.rs")));
Ok(())
@@ -67,7 +67,7 @@ async fn run_search_respects_limit() -> anyhow::Result<()> {
std::fs::write(dir.join("two.txt"), "alpha two").unwrap();
std::fs::write(dir.join("three.txt"), "alpha three").unwrap();
- let results = run_rg_search("alpha", None, dir, 2, dir).await?;
+ let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 2, dir).await?;
assert_eq!(results.len(), 2);
Ok(())
}
@@ -81,7 +81,7 @@ async fn run_search_handles_no_matches() -> anyhow::Result<()> {
let dir = temp.path();
std::fs::write(dir.join("one.txt"), "omega").unwrap();
- let results = run_rg_search("alpha", None, dir, 5, dir).await?;
+ let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 5, dir).await?;
assert!(results.is_empty());
Ok(())
}
diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs
index f0a62b8c1..5903d2b5c 100644
--- a/codex-rs/core/src/tools/handlers/mod.rs
+++ b/codex-rs/core/src/tools/handlers/mod.rs
@@ -1,6 +1,7 @@
pub(crate) mod agent_jobs;
pub mod apply_patch;
mod dynamic;
+mod grep_files;
mod js_repl;
mod list_dir;
mod mcp;
@@ -9,6 +10,7 @@ pub(crate) mod multi_agents;
pub(crate) mod multi_agents_common;
pub(crate) mod multi_agents_v2;
mod plan;
+mod read_file;
mod request_permissions;
mod request_user_input;
mod shell;
@@ -36,12 +38,14 @@ pub use apply_patch::ApplyPatchHandler;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
pub use dynamic::DynamicToolHandler;
+pub use grep_files::GrepFilesHandler;
pub use js_repl::JsReplHandler;
pub use js_repl::JsReplResetHandler;
pub use list_dir::ListDirHandler;
pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use plan::PlanHandler;
+pub use read_file::ReadFileHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_user_input::RequestUserInputHandler;
pub use shell::ShellCommandHandler;
diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
index 8e4bfb5b5..17c3362cd 100644
--- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
+++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
@@ -73,6 +73,14 @@ impl ToolHandler for Handler {
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
+ if let Some(cwd) = resolve_requested_agent_cwd(&turn.cwd, args.cwd.as_deref())? {
+ config.cwd =
+ codex_utils_absolute_path::AbsolutePathBuf::try_from(cwd).map_err(|error| {
+ FunctionCallError::RespondToModel(format!(
+ "spawn_agent cwd must be absolute: {error}"
+ ))
+ })?;
+ }
apply_spawn_agent_overrides(&mut config, child_depth);
let result = session
@@ -175,6 +183,7 @@ struct SpawnAgentArgs {
agent_type: Option,
model: Option,
reasoning_effort: Option,
+ cwd: Option,
#[serde(default)]
fork_context: bool,
}
diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs
index 2078c229b..e55dd2d18 100644
--- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs
+++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs
@@ -24,6 +24,8 @@ use codex_protocol::user_input::UserInput;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
+use std::path::Path;
+use std::path::PathBuf;
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
@@ -193,6 +195,44 @@ pub(crate) fn parse_collab_input(
}
}
+pub(crate) fn resolve_requested_agent_cwd(
+ parent_cwd: &Path,
+ requested_cwd: Option<&str>,
+) -> Result, FunctionCallError> {
+ let Some(requested_cwd) = requested_cwd else {
+ return Ok(None);
+ };
+
+ let requested_cwd = requested_cwd.trim();
+ if requested_cwd.is_empty() {
+ return Err(FunctionCallError::RespondToModel(
+ "spawn_agent cwd cannot be empty".to_string(),
+ ));
+ }
+
+ let requested_path = PathBuf::from(requested_cwd);
+ let resolved = if requested_path.is_absolute() {
+ requested_path
+ } else {
+ parent_cwd.join(requested_path)
+ };
+
+ if !resolved.exists() {
+ return Err(FunctionCallError::RespondToModel(format!(
+ "spawn_agent cwd {} does not exist",
+ resolved.display()
+ )));
+ }
+ if !resolved.is_dir() {
+ return Err(FunctionCallError::RespondToModel(format!(
+ "spawn_agent cwd {} is not a directory",
+ resolved.display()
+ )));
+ }
+
+ Ok(Some(resolved))
+}
+
/// Builds the base config snapshot for a newly spawned sub-agent.
///
/// The returned config starts from the parent's effective config and then refreshes the
diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs
index 8250d84f3..9e90a8974 100644
--- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs
+++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs
@@ -43,6 +43,7 @@ use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::user_input::UserInput;
+use core_test_support::PathExt;
use core_test_support::TempDirExt;
use pretty_assertions::assert_eq;
use serde::Deserialize;
@@ -324,6 +325,104 @@ async fn spawn_agent_returns_agent_id_without_task_name() {
assert_eq!(success, Some(true));
}
+#[tokio::test]
+async fn spawn_agent_applies_requested_cwd() {
+ #[derive(Debug, Deserialize)]
+ struct SpawnAgentResult {
+ agent_id: String,
+ }
+
+ let (mut session, mut turn) = make_session_and_context().await;
+ let manager = thread_manager();
+ session.services.agent_control = manager.agent_control();
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+ let child_workspace = temp_dir.path().join("worker-a");
+ std::fs::create_dir(&child_workspace).expect("create child workspace");
+ turn.cwd = temp_dir.path().abs();
+
+ let output = SpawnAgentHandler
+ .handle(invocation(
+ Arc::new(session),
+ Arc::new(turn),
+ "spawn_agent",
+ function_payload(json!({
+ "message": "inspect this repo",
+ "cwd": "worker-a"
+ })),
+ ))
+ .await
+ .expect("spawn_agent should succeed");
+ let (content, _) = expect_text_output(output);
+ let result: SpawnAgentResult =
+ serde_json::from_str(&content).expect("spawn_agent result should be json");
+
+ let snapshot = manager
+ .get_thread(parse_agent_id(&result.agent_id))
+ .await
+ .expect("spawned agent thread should exist")
+ .config_snapshot()
+ .await;
+ assert_eq!(snapshot.cwd, child_workspace);
+}
+
+#[tokio::test]
+async fn spawn_agent_rejects_missing_requested_cwd() {
+ let (session, mut turn) = make_session_and_context().await;
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+ turn.cwd = temp_dir.path().abs();
+
+ let invocation = invocation(
+ Arc::new(session),
+ Arc::new(turn),
+ "spawn_agent",
+ function_payload(json!({
+ "message": "inspect this repo",
+ "cwd": "missing"
+ })),
+ );
+ let Err(err) = SpawnAgentHandler.handle(invocation).await else {
+ panic!("missing cwd should be rejected");
+ };
+ assert_eq!(
+ err,
+ FunctionCallError::RespondToModel(format!(
+ "spawn_agent cwd {} does not exist",
+ temp_dir.path().join("missing").display()
+ ))
+ );
+}
+
+#[test]
+fn resolve_requested_agent_cwd_rejects_empty_path() {
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+
+ let err = resolve_requested_agent_cwd(temp_dir.path(), Some(" "))
+ .expect_err("empty cwd should be rejected");
+
+ assert_eq!(
+ err,
+ FunctionCallError::RespondToModel("spawn_agent cwd cannot be empty".to_string())
+ );
+}
+
+#[test]
+fn resolve_requested_agent_cwd_rejects_non_directory() {
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+ let file_path = temp_dir.path().join("worker.txt");
+ std::fs::write(&file_path, "hello").expect("write file");
+
+ let err = resolve_requested_agent_cwd(temp_dir.path(), Some("worker.txt"))
+ .expect_err("non-directory cwd should be rejected");
+
+ assert_eq!(
+ err,
+ FunctionCallError::RespondToModel(format!(
+ "spawn_agent cwd {} is not a directory",
+ file_path.display()
+ ))
+ );
+}
+
#[tokio::test]
async fn multi_agent_v2_spawn_requires_task_name() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -515,6 +614,70 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
}));
}
+#[tokio::test]
+async fn multi_agent_v2_spawn_applies_requested_cwd() {
+ #[derive(Debug, Deserialize)]
+ struct SpawnAgentResult {
+ task_name: String,
+ }
+
+ let (mut session, mut turn) = make_session_and_context().await;
+ let manager = thread_manager();
+ let root = manager
+ .start_thread((*turn.config).clone())
+ .await
+ .expect("root thread should start");
+ session.services.agent_control = manager.agent_control();
+ session.conversation_id = root.thread_id;
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+ let child_workspace = temp_dir.path().join("worker-b");
+ std::fs::create_dir(&child_workspace).expect("create child workspace");
+ turn.cwd = temp_dir.path().abs();
+ let mut config = (*turn.config).clone();
+ config
+ .features
+ .enable(Feature::MultiAgentV2)
+ .expect("test config should allow feature update");
+ turn.config = Arc::new(config);
+
+ let session = Arc::new(session);
+ let turn = Arc::new(turn);
+ let output = SpawnAgentHandlerV2
+ .handle(invocation(
+ session.clone(),
+ turn.clone(),
+ "spawn_agent",
+ function_payload(json!({
+ "message": "inspect this repo",
+ "task_name": "worker",
+ "cwd": "worker-b"
+ })),
+ ))
+ .await
+ .expect("spawn_agent should succeed");
+ let (content, _) = expect_text_output(output);
+ let result: SpawnAgentResult =
+ serde_json::from_str(&content).expect("spawn_agent result should be json");
+
+ let child_thread_id = session
+ .services
+ .agent_control
+ .resolve_agent_reference(
+ session.conversation_id,
+ &turn.session_source,
+ &result.task_name,
+ )
+ .await
+ .expect("spawned task name should resolve");
+ let snapshot = manager
+ .get_thread(child_thread_id)
+ .await
+ .expect("spawned agent thread should exist")
+ .config_snapshot()
+ .await;
+ assert_eq!(snapshot.cwd, child_workspace);
+}
+
#[tokio::test]
async fn multi_agent_v2_spawn_rejects_legacy_fork_context() {
let (mut session, mut turn) = make_session_and_context().await;
diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs
index 77c48af9e..5836ce479 100644
--- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs
+++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs
@@ -82,6 +82,14 @@ impl ToolHandler for Handler {
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
+ if let Some(cwd) = resolve_requested_agent_cwd(&turn.cwd, args.cwd.as_deref())? {
+ config.cwd =
+ codex_utils_absolute_path::AbsolutePathBuf::try_from(cwd).map_err(|error| {
+ FunctionCallError::RespondToModel(format!(
+ "spawn_agent cwd must be absolute: {error}"
+ ))
+ })?;
+ }
apply_spawn_agent_overrides(&mut config, child_depth);
config.developer_instructions = Some(
if let Some(existing_instructions) = config.developer_instructions.take() {
@@ -222,6 +230,7 @@ struct SpawnAgentArgs {
agent_type: Option,
model: Option,
reasoning_effort: Option,
+ cwd: Option,
fork_turns: Option,
fork_context: Option,
}
diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs
new file mode 100644
index 000000000..91a0e9a7d
--- /dev/null
+++ b/codex-rs/core/src/tools/handlers/read_file.rs
@@ -0,0 +1,563 @@
+use crate::function_tool::FunctionCallError;
+use crate::tools::context::FunctionToolOutput;
+use crate::tools::context::ToolInvocation;
+use crate::tools::context::ToolPayload;
+use crate::tools::handlers::parse_arguments;
+use crate::tools::registry::ToolHandler;
+use crate::tools::registry::ToolKind;
+use codex_utils_string::take_bytes_at_char_boundary;
+use serde::Deserialize;
+use std::path::Path;
+use std::path::PathBuf;
+use tokio::fs;
+
+pub struct ReadFileHandler;
+
+const DEFAULT_LIMIT: usize = 200;
+const DEFAULT_OFFSET: usize = 1;
+const MAX_LINE_LENGTH: usize = 2_000;
+
+fn default_limit() -> usize {
+ DEFAULT_LIMIT
+}
+
+fn default_offset() -> usize {
+ DEFAULT_OFFSET
+}
+
+fn default_max_levels() -> usize {
+ 1
+}
+
+fn default_include_header() -> bool {
+ true
+}
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+enum ReadFileMode {
+ #[default]
+ Slice,
+ Indentation,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct IndentationArgs {
+ #[serde(default)]
+ anchor_line: Option,
+ #[serde(default)]
+ include_siblings: bool,
+ #[serde(default = "default_max_levels")]
+ max_levels: usize,
+ #[serde(default = "default_include_header")]
+ include_header: bool,
+ #[serde(default)]
+ max_lines: Option,
+}
+
+impl Default for IndentationArgs {
+ fn default() -> Self {
+ Self {
+ anchor_line: None,
+ include_siblings: false,
+ max_levels: default_max_levels(),
+ include_header: default_include_header(),
+ max_lines: None,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct ReadFileArgs {
+ file_path: String,
+ #[serde(default = "default_offset")]
+ offset: usize,
+ #[serde(default = "default_limit")]
+ limit: usize,
+ #[serde(default)]
+ mode: ReadFileMode,
+ #[serde(default)]
+ indentation: Option,
+}
+
+impl ToolHandler for ReadFileHandler {
+ type Output = FunctionToolOutput;
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Function
+ }
+
+ async fn handle(&self, invocation: ToolInvocation) -> Result {
+ let ToolInvocation { payload, turn, .. } = invocation;
+ let arguments = match payload {
+ ToolPayload::Function { arguments } => arguments,
+ _ => {
+ return Err(FunctionCallError::RespondToModel(
+ "read_file handler received unsupported payload".to_string(),
+ ));
+ }
+ };
+
+ let args: ReadFileArgs = parse_arguments(&arguments)?;
+ validate_offset_limit(args.offset, args.limit)?;
+
+ let path = crate::util::resolve_path(turn.cwd.as_path(), &PathBuf::from(args.file_path));
+ let lines = match args.mode {
+ ReadFileMode::Slice => slice::read(path.as_path(), args.offset, args.limit).await?,
+ ReadFileMode::Indentation => {
+ indentation::read_block(
+ path.as_path(),
+ args.offset,
+ args.limit,
+ args.indentation.unwrap_or_default(),
+ )
+ .await?
+ }
+ };
+
+ Ok(FunctionToolOutput::from_text(lines.join("\n"), Some(true)))
+ }
+}
+
+#[derive(Clone, Debug)]
+struct LineRecord {
+ text: String,
+ indent: usize,
+ is_blank: bool,
+}
+
+impl LineRecord {
+ fn new(text: String) -> Self {
+ let is_blank = text.trim().is_empty();
+ let indent = text
+ .chars()
+ .take_while(|ch| matches!(ch, ' ' | '\t'))
+ .map(|ch| if ch == '\t' { 4 } else { 1 })
+ .sum();
+ Self {
+ text,
+ indent,
+ is_blank,
+ }
+ }
+
+ fn trimmed(&self) -> &str {
+ self.text.trim()
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+struct BlockRange {
+ start: usize,
+ actual_start: usize,
+ end: usize,
+}
+
+async fn load_lines(path: &Path) -> Result, FunctionCallError> {
+ let bytes = fs::read(path)
+ .await
+ .map_err(|err| FunctionCallError::RespondToModel(format!("failed to read file: {err}")))?;
+ let text = String::from_utf8_lossy(&bytes);
+ let mut lines = text
+ .split('\n')
+ .map(|line| LineRecord::new(line.trim_end_matches('\r').to_string()))
+ .collect::>();
+ if matches!(lines.last(), Some(last) if last.text.is_empty()) {
+ lines.pop();
+ }
+ Ok(lines)
+}
+
+fn validate_offset_limit(offset: usize, limit: usize) -> Result<(), FunctionCallError> {
+ if offset == 0 {
+ return Err(FunctionCallError::RespondToModel(
+ "offset must be a 1-indexed line number".to_string(),
+ ));
+ }
+ if limit == 0 {
+ return Err(FunctionCallError::RespondToModel(
+ "limit must be greater than zero".to_string(),
+ ));
+ }
+ Ok(())
+}
+
+fn format_output(
+ lines: &[LineRecord],
+ start: usize,
+ end_exclusive: usize,
+ max_lines: usize,
+) -> Vec {
+ lines[start..end_exclusive]
+ .iter()
+ .take(max_lines)
+ .enumerate()
+ .map(|(offset, line)| format_line(start + offset + 1, line.text.as_str()))
+ .collect()
+}
+
+fn format_line(line_number: usize, line: &str) -> String {
+ let truncated = if line.len() > MAX_LINE_LENGTH {
+ take_bytes_at_char_boundary(line, MAX_LINE_LENGTH).to_string()
+ } else {
+ line.to_string()
+ };
+ format!("L{line_number}: {truncated}")
+}
+
+fn resolve_anchor_index(
+ lines: &[LineRecord],
+ anchor_line: usize,
+) -> Result {
+ if anchor_line == 0 {
+ return Err(FunctionCallError::RespondToModel(
+ "anchor_line must be a 1-indexed line number".to_string(),
+ ));
+ }
+ let requested = anchor_line - 1;
+ if requested >= lines.len() {
+ return Err(FunctionCallError::RespondToModel(
+ "anchor_line exceeds file length".to_string(),
+ ));
+ }
+ if !lines[requested].is_blank {
+ return Ok(requested);
+ }
+ if let Some(previous) = (0..requested).rev().find(|index| !lines[*index].is_blank) {
+ return Ok(previous);
+ }
+ ((requested + 1)..lines.len())
+ .find(|index| !lines[*index].is_blank)
+ .ok_or_else(|| {
+ FunctionCallError::RespondToModel(
+ "cannot infer indentation from an empty file".to_string(),
+ )
+ })
+}
+
+fn find_ancestor_start(lines: &[LineRecord], anchor_index: usize, levels: usize) -> Option {
+ let mut current_index = anchor_index;
+ let mut current_indent = lines[anchor_index].indent;
+ let mut found = None;
+ for _ in 0..levels {
+ let next = (0..current_index)
+ .rev()
+ .find(|index| !lines[*index].is_blank && lines[*index].indent < current_indent)?;
+ found = Some(next);
+ current_index = next;
+ current_indent = lines[next].indent;
+ }
+ found
+}
+
+fn is_closing_line(line: &str) -> bool {
+ matches!(line.chars().next(), Some('}') | Some(']') | Some(')'))
+}
+
+fn is_header_line(line: &str) -> bool {
+ line.starts_with("//")
+ || line.starts_with("/*")
+ || line.starts_with('*')
+ || line.starts_with("#[")
+ || line.starts_with("#!")
+ || line.starts_with("///")
+ || line.starts_with("//!")
+ || line.starts_with('@')
+}
+
+fn extend_header_upwards(lines: &[LineRecord], actual_start: usize) -> usize {
+ let indent = lines[actual_start].indent;
+ let mut start = actual_start;
+ while start > 0 {
+ let previous = &lines[start - 1];
+ if previous.is_blank || previous.indent != indent || !is_header_line(previous.trimmed()) {
+ break;
+ }
+ start -= 1;
+ }
+ start
+}
+
+fn block_range(
+ lines: &[LineRecord],
+ actual_start: usize,
+ section_end: usize,
+ include_header: bool,
+) -> BlockRange {
+ let start = if include_header {
+ extend_header_upwards(lines, actual_start)
+ } else {
+ actual_start
+ };
+ let end = find_block_end(lines, actual_start, section_end);
+ BlockRange {
+ start,
+ actual_start,
+ end,
+ }
+}
+
+fn find_block_end(lines: &[LineRecord], actual_start: usize, section_end: usize) -> usize {
+ let start_indent = lines[actual_start].indent;
+ let mut saw_nested = false;
+ let mut last_included = actual_start;
+
+ for (index, line) in lines
+ .iter()
+ .enumerate()
+ .take(section_end + 1)
+ .skip(actual_start + 1)
+ {
+ if line.is_blank {
+ last_included = index;
+ continue;
+ }
+ if line.indent > start_indent {
+ saw_nested = true;
+ last_included = index;
+ continue;
+ }
+ if line.indent == start_indent && is_closing_line(line.trimmed()) {
+ return index;
+ }
+ if saw_nested {
+ return last_included;
+ }
+ return actual_start;
+ }
+
+ last_included
+}
+
+fn qualifies_as_block(lines: &[LineRecord], start: usize, section_end: usize) -> bool {
+ let start_indent = lines[start].indent;
+ for line in lines.iter().take(section_end + 1).skip(start + 1) {
+ if line.is_blank {
+ continue;
+ }
+ return line.indent > start_indent;
+ }
+ false
+}
+
+mod slice {
+ use super::FunctionCallError;
+ use super::LineRecord;
+ use super::Path;
+ use super::format_output;
+ use super::load_lines;
+ use super::validate_offset_limit;
+
+ pub(super) async fn read(
+ path: &Path,
+ offset: usize,
+ limit: usize,
+ ) -> Result, FunctionCallError> {
+ let lines = load_lines(path).await?;
+ read_loaded(lines.as_slice(), offset, limit)
+ }
+
+ pub(super) fn read_loaded(
+ lines: &[LineRecord],
+ offset: usize,
+ limit: usize,
+ ) -> Result, FunctionCallError> {
+ validate_offset_limit(offset, limit)?;
+ let start = offset - 1;
+ if start >= lines.len() {
+ return Err(FunctionCallError::RespondToModel(
+ "offset exceeds file length".to_string(),
+ ));
+ }
+ let end = (start + limit).min(lines.len());
+ Ok(format_output(lines, start, end, limit))
+ }
+}
+
+mod indentation {
+ use super::BlockRange;
+ use super::FunctionCallError;
+ use super::IndentationArgs;
+ use super::LineRecord;
+ use super::Path;
+ use super::block_range;
+ use super::find_ancestor_start;
+ use super::format_output;
+ use super::is_header_line;
+ use super::load_lines;
+ use super::qualifies_as_block;
+ use super::resolve_anchor_index;
+ use super::slice;
+ use super::validate_offset_limit;
+
+ pub(super) async fn read_block(
+ path: &Path,
+ offset: usize,
+ limit: usize,
+ options: IndentationArgs,
+ ) -> Result, FunctionCallError> {
+ validate_offset_limit(offset, limit)?;
+ if options.max_levels == 0 {
+ return Err(FunctionCallError::RespondToModel(
+ "indentation.max_levels must be greater than zero".to_string(),
+ ));
+ }
+
+ let max_lines = options.max_lines.unwrap_or(limit);
+ if max_lines == 0 {
+ return Err(FunctionCallError::RespondToModel(
+ "indentation.max_lines must be greater than zero".to_string(),
+ ));
+ }
+
+ let lines = load_lines(path).await?;
+ if lines.is_empty() || offset > lines.len() {
+ return Err(FunctionCallError::RespondToModel(
+ "offset exceeds file length".to_string(),
+ ));
+ }
+
+ let anchor_index = resolve_anchor_index(&lines, options.anchor_line.unwrap_or(offset))?;
+ let Some(actual_start) = find_ancestor_start(&lines, anchor_index, options.max_levels)
+ else {
+ return slice::read_loaded(lines.as_slice(), offset, max_lines);
+ };
+
+ let section_end = lines.len() - 1;
+ let mut selection = block_range(&lines, actual_start, section_end, options.include_header);
+ if options.include_siblings
+ && let Some(parent_start) = find_ancestor_start(&lines, actual_start, /*levels*/ 1)
+ {
+ let parent_scope = block_range(
+ &lines,
+ parent_start,
+ section_end,
+ /*include_header*/ false,
+ );
+ selection =
+ expand_with_siblings(&lines, parent_scope, selection, options.include_header);
+ }
+
+ let end_exclusive = (selection.end + 1).min(lines.len());
+ Ok(format_output(
+ &lines,
+ selection.start,
+ end_exclusive,
+ max_lines,
+ ))
+ }
+
+ fn expand_with_siblings(
+ lines: &[LineRecord],
+ parent_scope: BlockRange,
+ selected: BlockRange,
+ include_header: bool,
+ ) -> BlockRange {
+ let items = collect_scope_items(
+ lines,
+ parent_scope,
+ lines[selected.actual_start].indent,
+ include_header,
+ );
+ let Some(position) = items.iter().position(|item| match item {
+ ScopeItem::Block(block) => block.actual_start == selected.actual_start,
+ ScopeItem::Barrier => false,
+ }) else {
+ return selected;
+ };
+
+ let mut start = selected.start;
+ let mut end = selected.end;
+
+ let mut left = position;
+ while left > 0 {
+ match items[left - 1] {
+ ScopeItem::Block(block) => {
+ start = block.start;
+ left -= 1;
+ }
+ ScopeItem::Barrier => break,
+ }
+ }
+
+ let mut right = position;
+ while right + 1 < items.len() {
+ match items[right + 1] {
+ ScopeItem::Block(block) => {
+ end = block.end;
+ right += 1;
+ }
+ ScopeItem::Barrier => break,
+ }
+ }
+
+ BlockRange {
+ start,
+ actual_start: selected.actual_start,
+ end,
+ }
+ }
+
+ fn collect_scope_items(
+ lines: &[LineRecord],
+ parent_scope: BlockRange,
+ indent: usize,
+ include_header: bool,
+ ) -> Vec {
+ let mut items = Vec::new();
+ let mut index = parent_scope.actual_start.saturating_add(1);
+ while index <= parent_scope.end {
+ let line = &lines[index];
+ if line.is_blank || line.indent != indent {
+ index += 1;
+ continue;
+ }
+
+ if is_header_line(line.trimmed()) {
+ let header_start = index;
+ let mut actual_start = index;
+ while actual_start <= parent_scope.end
+ && !lines[actual_start].is_blank
+ && lines[actual_start].indent == indent
+ && is_header_line(lines[actual_start].trimmed())
+ {
+ actual_start += 1;
+ }
+ if actual_start <= parent_scope.end
+ && lines[actual_start].indent == indent
+ && qualifies_as_block(lines, actual_start, parent_scope.end)
+ {
+ let mut block =
+ block_range(lines, actual_start, parent_scope.end, include_header);
+ block.start = header_start;
+ items.push(ScopeItem::Block(block));
+ index = block.end + 1;
+ continue;
+ }
+ items.push(ScopeItem::Barrier);
+ index = actual_start;
+ continue;
+ }
+
+ if qualifies_as_block(lines, index, parent_scope.end) {
+ let block = block_range(lines, index, parent_scope.end, include_header);
+ items.push(ScopeItem::Block(block));
+ index = block.end + 1;
+ } else {
+ items.push(ScopeItem::Barrier);
+ index += 1;
+ }
+ }
+ items
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ enum ScopeItem {
+ Block(BlockRange),
+ Barrier,
+ }
+}
+
+#[cfg(test)]
+#[path = "read_file_tests.rs"]
+mod tests;
diff --git a/codex-rs/core/src/tools/handlers/read_file_tests.rs b/codex-rs/core/src/tools/handlers/read_file_tests.rs
index 3921a9882..79f16d705 100644
--- a/codex-rs/core/src/tools/handlers/read_file_tests.rs
+++ b/codex-rs/core/src/tools/handlers/read_file_tests.rs
@@ -16,7 +16,7 @@ gamma
"
)?;
- let lines = read(temp.path(), 2, 2).await?;
+ let lines = read(temp.path(), /*offset*/ 2, /*limit*/ 2).await?;
assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]);
Ok(())
}
@@ -27,7 +27,7 @@ async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> {
use std::io::Write as _;
writeln!(temp, "only")?;
- let err = read(temp.path(), 3, 1)
+ let err = read(temp.path(), /*offset*/ 3, /*limit*/ 1)
.await
.expect_err("offset exceeds length");
assert_eq!(
@@ -43,7 +43,7 @@ async fn reads_non_utf8_lines() -> anyhow::Result<()> {
use std::io::Write as _;
temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?;
- let lines = read(temp.path(), 1, 2).await?;
+ let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?;
let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}');
assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]);
Ok(())
@@ -55,7 +55,7 @@ async fn trims_crlf_endings() -> anyhow::Result<()> {
use std::io::Write as _;
write!(temp, "one\r\ntwo\r\n")?;
- let lines = read(temp.path(), 1, 2).await?;
+ let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?;
assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]);
Ok(())
}
@@ -72,7 +72,7 @@ third
"
)?;
- let lines = read(temp.path(), 1, 2).await?;
+ let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?;
assert_eq!(
lines,
vec!["L1: first".to_string(), "L2: second".to_string()]
@@ -87,7 +87,7 @@ async fn truncates_lines_longer_than_max_length() -> anyhow::Result<()> {
let long_line = "x".repeat(MAX_LINE_LENGTH + 50);
writeln!(temp, "{long_line}")?;
- let lines = read(temp.path(), 1, 1).await?;
+ let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 1).await?;
let expected = "x".repeat(MAX_LINE_LENGTH);
assert_eq!(lines, vec![format!("L1: {expected}")]);
Ok(())
@@ -115,7 +115,7 @@ async fn indentation_mode_captures_block() -> anyhow::Result<()> {
..Default::default()
};
- let lines = read_block(temp.path(), 3, 10, options).await?;
+ let lines = read_block(temp.path(), /*offset*/ 3, /*limit*/ 10, options).await?;
assert_eq!(
lines,
@@ -150,7 +150,13 @@ async fn indentation_mode_expands_parents() -> anyhow::Result<()> {
..Default::default()
};
- let lines = read_block(temp.path(), 4, 50, options.clone()).await?;
+ let lines = read_block(
+ temp.path(),
+ /*offset*/ 4,
+ /*limit*/ 50,
+ options.clone(),
+ )
+ .await?;
assert_eq!(
lines,
vec![
@@ -163,7 +169,7 @@ async fn indentation_mode_expands_parents() -> anyhow::Result<()> {
);
options.max_levels = 3;
- let expanded = read_block(temp.path(), 4, 50, options).await?;
+ let expanded = read_block(temp.path(), /*offset*/ 4, /*limit*/ 50, options).await?;
assert_eq!(
expanded,
vec![
@@ -203,7 +209,13 @@ async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> {
..Default::default()
};
- let lines = read_block(temp.path(), 3, 50, options.clone()).await?;
+ let lines = read_block(
+ temp.path(),
+ /*offset*/ 3,
+ /*limit*/ 50,
+ options.clone(),
+ )
+ .await?;
assert_eq!(
lines,
vec![
@@ -214,7 +226,7 @@ async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> {
);
options.include_siblings = true;
- let with_siblings = read_block(temp.path(), 3, 50, options).await?;
+ let with_siblings = read_block(temp.path(), /*offset*/ 3, /*limit*/ 50, options).await?;
assert_eq!(
with_siblings,
vec![
@@ -257,7 +269,7 @@ class Bar:
..Default::default()
};
- let lines = read_block(temp.path(), 1, 200, options).await?;
+ let lines = read_block(temp.path(), /*offset*/ 1, /*limit*/ 200, options).await?;
assert_eq!(
lines,
vec![
@@ -313,7 +325,7 @@ export function other() {{
..Default::default()
};
- let lines = read_block(temp.path(), 15, 200, options).await?;
+ let lines = read_block(temp.path(), /*offset*/ 15, /*limit*/ 200, options).await?;
assert_eq!(
lines,
vec![
@@ -385,7 +397,7 @@ async fn indentation_mode_handles_cpp_sample_shallow() -> anyhow::Result<()> {
..Default::default()
};
- let lines = read_block(temp.path(), 18, 200, options).await?;
+ let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?;
assert_eq!(
lines,
vec![
@@ -413,7 +425,7 @@ async fn indentation_mode_handles_cpp_sample() -> anyhow::Result<()> {
..Default::default()
};
- let lines = read_block(temp.path(), 18, 200, options).await?;
+ let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?;
assert_eq!(
lines,
vec![
@@ -445,7 +457,7 @@ async fn indentation_mode_handles_cpp_sample_no_headers() -> anyhow::Result<()>
..Default::default()
};
- let lines = read_block(temp.path(), 18, 200, options).await?;
+ let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?;
assert_eq!(
lines,
vec![
@@ -476,7 +488,7 @@ async fn indentation_mode_handles_cpp_sample_siblings() -> anyhow::Result<()> {
..Default::default()
};
- let lines = read_block(temp.path(), 18, 200, options).await?;
+ let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?;
assert_eq!(
lines,
vec![
diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs
index dcc9445a9..09c9b2fac 100644
--- a/codex-rs/core/src/tools/handlers/request_user_input.rs
+++ b/codex-rs/core/src/tools/handlers/request_user_input.rs
@@ -6,8 +6,9 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
-use codex_tools::REQUEST_USER_INPUT_TOOL_NAME;
-use codex_tools::normalize_request_user_input_args;
+use codex_tools::QUESTION_TOOL_NAME;
+use codex_tools::normalize_request_user_input_args_for_tool;
+use codex_tools::question_unavailable_message;
use codex_tools::request_user_input_unavailable_message;
pub struct RequestUserInputHandler {
@@ -26,6 +27,7 @@ impl ToolHandler for RequestUserInputHandler {
session,
turn,
call_id,
+ tool_name,
payload,
..
} = invocation;
@@ -34,34 +36,34 @@ impl ToolHandler for RequestUserInputHandler {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(format!(
- "{REQUEST_USER_INPUT_TOOL_NAME} handler received unsupported payload"
+ "{tool_name} handler received unsupported payload"
)));
}
};
let mode = session.collaboration_mode().await.mode;
- if let Some(message) =
- request_user_input_unavailable_message(mode, self.default_mode_request_user_input)
- {
+ let unavailable_message = match tool_name.as_str() {
+ QUESTION_TOOL_NAME => question_unavailable_message(mode),
+ _ => request_user_input_unavailable_message(mode, self.default_mode_request_user_input),
+ };
+ if let Some(message) = unavailable_message {
return Err(FunctionCallError::RespondToModel(message));
}
let args: RequestUserInputArgs = parse_arguments(&arguments)?;
- let args =
- normalize_request_user_input_args(args).map_err(FunctionCallError::RespondToModel)?;
+ let args = normalize_request_user_input_args_for_tool(&tool_name, args)
+ .map_err(FunctionCallError::RespondToModel)?;
let response = session
.request_user_input(turn.as_ref(), call_id, args)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
- "{REQUEST_USER_INPUT_TOOL_NAME} was cancelled before receiving a response"
+ "{tool_name} was cancelled before receiving a response"
))
})?;
let content = serde_json::to_string(&response).map_err(|err| {
- FunctionCallError::Fatal(format!(
- "failed to serialize {REQUEST_USER_INPUT_TOOL_NAME} response: {err}"
- ))
+ FunctionCallError::Fatal(format!("failed to serialize {tool_name} response: {err}"))
})?;
Ok(FunctionToolOutput::from_text(content, Some(true)))
diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs
index 90b9a382a..e05117eb2 100644
--- a/codex-rs/core/src/tools/spec.rs
+++ b/codex-rs/core/src/tools/spec.rs
@@ -40,12 +40,14 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::CodeModeExecuteHandler;
use crate::tools::handlers::CodeModeWaitHandler;
use crate::tools::handlers::DynamicToolHandler;
+ use crate::tools::handlers::GrepFilesHandler;
use crate::tools::handlers::JsReplHandler;
use crate::tools::handlers::JsReplResetHandler;
use crate::tools::handlers::ListDirHandler;
use crate::tools::handlers::McpHandler;
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::PlanHandler;
+ use crate::tools::handlers::ReadFileHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::ShellCommandHandler;
@@ -154,6 +156,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
ToolHandlerKind::FollowupTaskV2 => {
builder.register_handler(handler.name, Arc::new(FollowupTaskHandlerV2));
}
+ ToolHandlerKind::GrepFiles => {
+ builder.register_handler(handler.name, Arc::new(GrepFilesHandler));
+ }
ToolHandlerKind::JsRepl => {
builder.register_handler(handler.name, js_repl_handler.clone());
}
@@ -175,6 +180,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
ToolHandlerKind::Plan => {
builder.register_handler(handler.name, plan_handler.clone());
}
+ ToolHandlerKind::ReadFile => {
+ builder.register_handler(handler.name, Arc::new(ReadFileHandler));
+ }
ToolHandlerKind::RequestPermissions => {
builder.register_handler(handler.name, request_permissions_handler.clone());
}
diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs
index 4615b4531..0e050265e 100644
--- a/codex-rs/core/src/tools/spec_tests.rs
+++ b/codex-rs/core/src/tools/spec_tests.rs
@@ -300,6 +300,7 @@ fn test_build_specs_gpt5_codex_default() {
"shell_command",
&[
"update_plan",
+ "question",
"request_user_input",
"apply_patch",
"web_search",
@@ -323,6 +324,7 @@ fn test_build_specs_gpt51_codex_default() {
"shell_command",
&[
"update_plan",
+ "question",
"request_user_input",
"apply_patch",
"web_search",
@@ -336,6 +338,38 @@ fn test_build_specs_gpt51_codex_default() {
);
}
+#[test]
+fn experimental_read_and_grep_tools_register_handlers() {
+ let config = test_config();
+ let mut model_info = construct_model_info_offline("gpt-5-codex", &config);
+ model_info.experimental_supported_tools =
+ vec!["read_file".to_string(), "grep_files".to_string()];
+ let features = Features::with_defaults();
+ let available_models = Vec::new();
+ let tools_config = ToolsConfig::new(&ToolsConfigParams {
+ model_info: &model_info,
+ available_models: &available_models,
+ features: &features,
+ web_search_mode: Some(WebSearchMode::Cached),
+ session_source: SessionSource::Cli,
+ sandbox_policy: &SandboxPolicy::DangerFullAccess,
+ windows_sandbox_level: WindowsSandboxLevel::Disabled,
+ });
+
+ let (tools, registry) = build_specs(
+ &tools_config,
+ /*mcp_tools*/ None,
+ /*app_tools*/ None,
+ &[],
+ )
+ .build();
+
+ assert!(tools.iter().any(|tool| tool.name() == "read_file"));
+ assert!(tools.iter().any(|tool| tool.name() == "grep_files"));
+ assert!(registry.has_handler("read_file", /*namespace*/ None));
+ assert!(registry.has_handler("grep_files", /*namespace*/ None));
+}
+
#[test]
fn test_build_specs_gpt5_codex_unified_exec_web_search() {
let mut features = Features::with_defaults();
@@ -348,6 +382,7 @@ fn test_build_specs_gpt5_codex_unified_exec_web_search() {
"exec_command",
"write_stdin",
"update_plan",
+ "question",
"request_user_input",
"apply_patch",
"web_search",
@@ -373,6 +408,7 @@ fn test_build_specs_gpt51_codex_unified_exec_web_search() {
"exec_command",
"write_stdin",
"update_plan",
+ "question",
"request_user_input",
"apply_patch",
"web_search",
@@ -396,6 +432,7 @@ fn test_gpt_5_1_codex_max_defaults() {
"shell_command",
&[
"update_plan",
+ "question",
"request_user_input",
"apply_patch",
"web_search",
@@ -419,6 +456,7 @@ fn test_codex_5_1_mini_defaults() {
"shell_command",
&[
"update_plan",
+ "question",
"request_user_input",
"apply_patch",
"web_search",
@@ -442,6 +480,7 @@ fn test_gpt_5_defaults() {
"shell",
&[
"update_plan",
+ "question",
"request_user_input",
"web_search",
"view_image",
@@ -464,6 +503,7 @@ fn test_gpt_5_1_defaults() {
"shell_command",
&[
"update_plan",
+ "question",
"request_user_input",
"apply_patch",
"web_search",
@@ -489,6 +529,7 @@ fn test_gpt_5_1_codex_max_unified_exec_web_search() {
"exec_command",
"write_stdin",
"update_plan",
+ "question",
"request_user_input",
"apply_patch",
"web_search",
diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs
index 8e30b37c2..181a1fe24 100644
--- a/codex-rs/core/tests/suite/request_user_input.rs
+++ b/codex-rs/core/tests/suite/request_user_input.rs
@@ -30,6 +30,9 @@ use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
+const QUESTION_TOOL_NAME: &str = "question";
+const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";
+
fn call_output(req: &ResponsesRequest, call_id: &str) -> String {
let raw = req.function_call_output(call_id);
assert_eq!(
@@ -74,6 +77,34 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()>
}
async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Result<()> {
+ interactive_question_round_trip_for_mode(
+ REQUEST_USER_INPUT_TOOL_NAME,
+ mode,
+ multiple_choice_request_args(),
+ /*expect_is_other*/ true,
+ mode == ModeKind::Default,
+ )
+ .await
+}
+
+async fn question_round_trip_for_mode(mode: ModeKind) -> anyhow::Result<()> {
+ interactive_question_round_trip_for_mode(
+ QUESTION_TOOL_NAME,
+ mode,
+ freeform_question_request_args(),
+ /*expect_is_other*/ false,
+ /*enable_default_mode_request_user_input*/ false,
+ )
+ .await
+}
+
+async fn interactive_question_round_trip_for_mode(
+ tool_name: &str,
+ mode: ModeKind,
+ request_args: Value,
+ expect_is_other: bool,
+ enable_default_mode_request_user_input: bool,
+) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -87,36 +118,25 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
..
} = builder
.with_config(move |config| {
- if mode == ModeKind::Default {
- config
- .features
- .enable(Feature::DefaultModeRequestUserInput)
- .expect("test config should allow feature update");
+ if enable_default_mode_request_user_input {
+ assert!(
+ config
+ .features
+ .enable(Feature::DefaultModeRequestUserInput)
+ .is_ok(),
+ "test config should allow feature update"
+ );
}
})
.build(&server)
.await?;
- let call_id = "user-input-call";
- let request_args = json!({
- "questions": [{
- "id": "confirm_path",
- "header": "Confirm",
- "question": "Proceed with the plan?",
- "options": [{
- "label": "Yes (Recommended)",
- "description": "Continue the current plan."
- }, {
- "label": "No",
- "description": "Stop and revisit the approach."
- }]
- }]
- })
- .to_string();
+ let call_id = format!("{tool_name}-call");
+ let request_args = request_args.to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
- ev_function_call(call_id, "request_user_input", &request_args),
+ ev_function_call(&call_id, tool_name, &request_args),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
@@ -163,7 +183,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
.await;
assert_eq!(request.call_id, call_id);
assert_eq!(request.questions.len(), 1);
- assert_eq!(request.questions[0].is_other, true);
+ assert_eq!(request.questions[0].is_other, expect_is_other);
let mut answers = HashMap::new();
answers.insert(
@@ -183,7 +203,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let req = second_mock.single_request();
- let output_text = call_output(&req, call_id);
+ let output_text = call_output(&req, &call_id);
let output_json: Value = serde_json::from_str(&output_text)?;
assert_eq!(
output_json,
@@ -198,6 +218,43 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
}
async fn assert_request_user_input_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()>
+where
+ F: FnOnce(String) -> CollaborationMode,
+{
+ assert_interactive_question_rejected(
+ REQUEST_USER_INPUT_TOOL_NAME,
+ mode_name,
+ multiple_choice_request_args(),
+ format!("request_user_input is unavailable in {mode_name} mode"),
+ /*enable_default_mode_request_user_input*/ false,
+ build_mode,
+ )
+ .await
+}
+
+async fn assert_question_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()>
+where
+ F: FnOnce(String) -> CollaborationMode,
+{
+ assert_interactive_question_rejected(
+ QUESTION_TOOL_NAME,
+ mode_name,
+ freeform_question_request_args(),
+ format!("question is unavailable in {mode_name} mode"),
+ /*enable_default_mode_request_user_input*/ false,
+ build_mode,
+ )
+ .await
+}
+
+async fn assert_interactive_question_rejected(
+ tool_name: &str,
+ mode_name: &str,
+ request_args: Value,
+ expected_output: String,
+ enable_default_mode_request_user_input: bool,
+ build_mode: F,
+) -> anyhow::Result<()>
where
F: FnOnce(String) -> CollaborationMode,
{
@@ -205,35 +262,34 @@ where
let server = start_mock_server().await;
- let mut builder = test_codex();
+ let builder = test_codex();
let TestCodex {
codex,
cwd,
session_configured,
..
- } = builder.build(&server).await?;
+ } = builder
+ .with_config(move |config| {
+ if enable_default_mode_request_user_input {
+ assert!(
+ config
+ .features
+ .enable(Feature::DefaultModeRequestUserInput)
+ .is_ok(),
+ "test config should allow feature update"
+ );
+ }
+ })
+ .build(&server)
+ .await?;
let mode_slug = mode_name.to_lowercase().replace(' ', "-");
- let call_id = format!("user-input-{mode_slug}-call");
- let request_args = json!({
- "questions": [{
- "id": "confirm_path",
- "header": "Confirm",
- "question": "Proceed with the plan?",
- "options": [{
- "label": "Yes (Recommended)",
- "description": "Continue the current plan."
- }, {
- "label": "No",
- "description": "Stop and revisit the approach."
- }]
- }]
- })
- .to_string();
+ let call_id = format!("{tool_name}-{mode_slug}-call");
+ let request_args = request_args.to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
- ev_function_call(&call_id, "request_user_input", &request_args),
+ ev_function_call(&call_id, tool_name, &request_args),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
@@ -272,14 +328,38 @@ where
let req = second_mock.single_request();
let (output, success) = call_output_content_and_success(&req, &call_id);
assert_eq!(success, None);
- assert_eq!(
- output,
- format!("request_user_input is unavailable in {mode_name} mode")
- );
+ assert_eq!(output, expected_output);
Ok(())
}
+fn multiple_choice_request_args() -> Value {
+ json!({
+ "questions": [{
+ "id": "confirm_path",
+ "header": "Confirm",
+ "question": "Proceed with the plan?",
+ "options": [{
+ "label": "Yes (Recommended)",
+ "description": "Continue the current plan."
+ }, {
+ "label": "No",
+ "description": "Stop and revisit the approach."
+ }]
+ }]
+ })
+}
+
+fn freeform_question_request_args() -> Value {
+ json!({
+ "questions": [{
+ "id": "details",
+ "header": "Details",
+ "question": "What should we keep?"
+ }]
+ })
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_rejected_in_execute_mode_alias() -> anyhow::Result<()> {
assert_request_user_input_rejected("Execute", |model| CollaborationMode {
@@ -323,3 +403,39 @@ async fn request_user_input_rejected_in_pair_mode_alias() -> anyhow::Result<()>
})
.await
}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn question_round_trip_resolves_pending_in_plan_mode() -> anyhow::Result<()> {
+ question_round_trip_for_mode(ModeKind::Plan).await
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn question_round_trip_resolves_pending_in_default_mode() -> anyhow::Result<()> {
+ question_round_trip_for_mode(ModeKind::Default).await
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn question_rejected_in_execute_mode_alias() -> anyhow::Result<()> {
+ assert_question_rejected("Execute", |model| CollaborationMode {
+ mode: ModeKind::Execute,
+ settings: Settings {
+ model,
+ reasoning_effort: None,
+ developer_instructions: None,
+ },
+ })
+ .await
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn question_rejected_in_pair_mode_alias() -> anyhow::Result<()> {
+ assert_question_rejected("Pair Programming", |model| CollaborationMode {
+ mode: ModeKind::PairProgramming,
+ settings: Settings {
+ model,
+ reasoning_effort: None,
+ developer_instructions: None,
+ },
+ })
+ .await
+}
diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml
index 3dd27c8f9..1fe4f3627 100644
--- a/codex-rs/deny.toml
+++ b/codex-rs/deny.toml
@@ -83,6 +83,7 @@ ignore = [
# TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities.
{ id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
{ id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
+ { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is pulled in only by PR5's clawbot/feishu tail via openlark; upstream stable openlark has not removed it yet, so this advisory is temporarily ignored until that dependency can be upgraded separately" },
]
# If this is true, then cargo deny will use the git executable to fetch advisory database.
# If this is false, then it uses a built-in git library.
diff --git a/codex-rs/models-manager/src/collaboration_mode_presets.rs b/codex-rs/models-manager/src/collaboration_mode_presets.rs
index f67ab91db..5404374c1 100644
--- a/codex-rs/models-manager/src/collaboration_mode_presets.rs
+++ b/codex-rs/models-manager/src/collaboration_mode_presets.rs
@@ -55,7 +55,6 @@ fn default_preset(collaboration_modes_config: CollaborationModesConfig) -> Colla
fn default_mode_instructions(collaboration_modes_config: CollaborationModesConfig) -> String {
let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES);
let request_user_input_availability = request_user_input_availability_message(
- ModeKind::Default,
collaboration_modes_config.default_mode_request_user_input,
);
let asking_questions_guidance = asking_questions_guidance_message(
@@ -86,27 +85,22 @@ fn format_mode_names(modes: &[ModeKind]) -> String {
}
}
-fn request_user_input_availability_message(
- mode: ModeKind,
- default_mode_request_user_input: bool,
-) -> String {
- let mode_name = mode.display_name();
- if mode.allows_request_user_input()
- || (default_mode_request_user_input && mode == ModeKind::Default)
- {
- format!("The `request_user_input` tool is available in {mode_name} mode.")
+fn request_user_input_availability_message(default_mode_request_user_input: bool) -> String {
+ let mode_name = ModeKind::Default.display_name();
+ if default_mode_request_user_input {
+ format!("The `question` and `request_user_input` tools are available in {mode_name} mode.")
} else {
format!(
- "The `request_user_input` tool is unavailable in {mode_name} mode. If you call it while in {mode_name} mode, it will return an error."
+ "The `question` tool is available in {mode_name} mode. The legacy `request_user_input` tool is unavailable in {mode_name} mode and will return an error."
)
}
}
fn asking_questions_guidance_message(default_mode_request_user_input: bool) -> String {
if default_mode_request_user_input {
- "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `request_user_input` tool rather than writing a multiple choice question as a textual assistant message. Never write a multiple choice question as a textual assistant message.".to_string()
+ "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `question` tool rather than writing a multiple choice question as a textual assistant message. The legacy `request_user_input` tool is still available in Default mode. Never write a multiple choice question as a textual assistant message.".to_string()
} else {
- "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.".to_string()
+ "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `question` tool rather than writing a multiple choice question as a textual assistant message. For a single simple clarification, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.".to_string()
}
}
diff --git a/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs b/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs
index 361c51762..cc4542133 100644
--- a/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs
+++ b/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs
@@ -31,12 +31,11 @@ fn default_mode_instructions_replace_mode_names_placeholder() {
let expected_snippet = format!("Known mode names are {known_mode_names}.");
assert!(default_instructions.contains(&expected_snippet));
- let expected_availability_message = request_user_input_availability_message(
- ModeKind::Default,
- /*default_mode_request_user_input*/ true,
- );
+ let expected_availability_message =
+ request_user_input_availability_message(/*default_mode_request_user_input*/ true);
assert!(default_instructions.contains(&expected_availability_message));
- assert!(default_instructions.contains("prefer using the `request_user_input` tool"));
+ assert!(default_instructions.contains("prefer using the `question` tool"));
+ assert!(default_instructions.contains("legacy `request_user_input` tool is still available"));
}
#[test]
@@ -47,6 +46,7 @@ fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() {
.expect("default instructions should be set");
assert!(!default_instructions.contains("prefer using the `request_user_input` tool"));
+ assert!(default_instructions.contains("prefer using the `question` tool"));
assert!(
default_instructions.contains("ask the user directly with a concise plain-text question")
);
diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs
index 3ffdeb48e..e11abca1e 100644
--- a/codex-rs/protocol/src/error.rs
+++ b/codex-rs/protocol/src/error.rs
@@ -220,6 +220,16 @@ impl CodexErr {
CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: self.http_status_code_value(),
},
+ CodexErr::UnexpectedStatus(err) => match err.status.as_u16() {
+ 401 | 403 => CodexErrorInfo::Unauthorized,
+ 429 => CodexErrorInfo::ResponseTooManyFailedAttempts {
+ http_status_code: Some(429),
+ },
+ 500..=599 => CodexErrorInfo::ResponseTooManyFailedAttempts {
+ http_status_code: Some(err.status.as_u16()),
+ },
+ _ => CodexErrorInfo::Other,
+ },
CodexErr::ConnectionFailed(_) => CodexErrorInfo::HttpConnectionFailed {
http_status_code: self.http_status_code_value(),
},
diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs
index 0b1d18972..3ccaf6a06 100644
--- a/codex-rs/protocol/src/error_tests.rs
+++ b/codex-rs/protocol/src/error_tests.rs
@@ -71,6 +71,41 @@ fn server_overloaded_maps_to_protocol() {
);
}
+#[test]
+fn unexpected_status_503_maps_to_response_too_many_failed_attempts() {
+ let err = CodexErr::UnexpectedStatus(UnexpectedResponseError {
+ status: StatusCode::SERVICE_UNAVAILABLE,
+ body: "Service temporarily unavailable".to_string(),
+ url: Some("https://example.com/v1/responses".to_string()),
+ cf_ray: None,
+ request_id: None,
+ identity_authorization_error: None,
+ identity_error_code: None,
+ });
+
+ assert_eq!(
+ err.to_codex_protocol_error(),
+ CodexErrorInfo::ResponseTooManyFailedAttempts {
+ http_status_code: Some(503),
+ }
+ );
+}
+
+#[test]
+fn unexpected_status_401_maps_to_unauthorized() {
+ let err = CodexErr::UnexpectedStatus(UnexpectedResponseError {
+ status: StatusCode::UNAUTHORIZED,
+ body: "Unauthorized".to_string(),
+ url: Some("https://example.com/v1/responses".to_string()),
+ cf_ray: None,
+ request_id: None,
+ identity_authorization_error: None,
+ identity_error_code: None,
+ });
+
+ assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::Unauthorized);
+}
+
#[test]
fn sandbox_denied_uses_aggregated_output_when_stderr_empty() {
let output = ExecToolCallOutput {
diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md
index cafb4b89a..419cd0424 100644
--- a/codex-rs/tools/README.md
+++ b/codex-rs/tools/README.md
@@ -26,7 +26,7 @@ schema and Responses API tool primitives that no longer need to live in
- MCP resource, `list_dir`, and `test_sync_tool` spec builders
- local host tool spec builders for shell/exec/request-permissions/view-image
- collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close,
- `request_user_input`, and CSV fanout/reporting
+ `question`, `request_user_input`, and CSV fanout/reporting
- discoverable-tool models, client filtering, and `ToolSpec` builders for
`tool_search` and `tool_suggest`
- `parse_tool_input_schema()`
diff --git a/codex-rs/tools/src/agent_tool.rs b/codex-rs/tools/src/agent_tool.rs
index 017cc6e5e..1570206ac 100644
--- a/codex-rs/tools/src/agent_tool.rs
+++ b/codex-rs/tools/src/agent_tool.rs
@@ -590,6 +590,15 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap ToolSpec {
+ create_interactive_question_tool(
+ QUESTION_TOOL_NAME,
+ QuestionToolSchema {
+ questions_description: "Questions to show the user. There is no fixed maximum; use as many as needed for the form.",
+ prompt_description: "Prompt shown to the user for this field.",
+ options_description: "Optional mutually exclusive choices for this question. Omit this field for a freeform text answer. When provided, put the recommended option first and do not include an \"Other\" option; the client can collect additional notes separately.",
+ options_required: false,
+ },
+ description,
+ )
+}
+
pub fn create_request_user_input_tool(description: String) -> ToolSpec {
+ create_interactive_question_tool(
+ REQUEST_USER_INPUT_TOOL_NAME,
+ QuestionToolSchema {
+ questions_description: "Questions to show the user. Prefer 1 and do not exceed 3",
+ prompt_description: "Single-sentence prompt shown to the user.",
+ options_description: "Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically.",
+ options_required: true,
+ },
+ description,
+ )
+}
+
+struct QuestionToolSchema {
+ questions_description: &'static str,
+ prompt_description: &'static str,
+ options_description: &'static str,
+ options_required: bool,
+}
+
+fn create_interactive_question_tool(
+ name: &str,
+ schema: QuestionToolSchema,
+ description: String,
+) -> ToolSpec {
let option_props = BTreeMap::from([
(
"label".to_string(),
@@ -27,10 +71,7 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec {
]);
let options_schema = JsonSchema::Array {
- description: Some(
- "Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
- .to_string(),
- ),
+ description: Some(schema.options_description.to_string()),
items: Box::new(JsonSchema::Object {
properties: option_props,
required: Some(vec!["label".to_string(), "description".to_string()]),
@@ -58,22 +99,27 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec {
(
"question".to_string(),
JsonSchema::String {
- description: Some("Single-sentence prompt shown to the user.".to_string()),
+ description: Some(schema.prompt_description.to_string()),
},
),
("options".to_string(), options_schema),
]);
let questions_schema = JsonSchema::Array {
- description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
+ description: Some(schema.questions_description.to_string()),
items: Box::new(JsonSchema::Object {
properties: question_props,
- required: Some(vec![
- "id".to_string(),
- "header".to_string(),
- "question".to_string(),
- "options".to_string(),
- ]),
+ required: Some({
+ let mut required = vec![
+ "id".to_string(),
+ "header".to_string(),
+ "question".to_string(),
+ ];
+ if schema.options_required {
+ required.push("options".to_string());
+ }
+ required
+ }),
additional_properties: Some(false.into()),
}),
};
@@ -81,7 +127,7 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec {
let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);
ToolSpec::Function(ResponsesApiTool {
- name: REQUEST_USER_INPUT_TOOL_NAME.to_string(),
+ name: name.to_string(),
description,
strict: false,
defer_loading: None,
@@ -94,6 +140,10 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec {
})
}
+fn question_is_available(mode: ModeKind) -> bool {
+ mode.allows_request_user_input() || mode == ModeKind::Default
+}
+
pub fn request_user_input_unavailable_message(
mode: ModeKind,
default_mode_request_user_input: bool,
@@ -108,28 +158,81 @@ pub fn request_user_input_unavailable_message(
}
}
+pub fn question_unavailable_message(mode: ModeKind) -> Option {
+ if question_is_available(mode) {
+ None
+ } else {
+ let mode_name = mode.display_name();
+ Some(format!("question is unavailable in {mode_name} mode"))
+ }
+}
+
+fn tool_is_available(
+ tool_name: &str,
+ mode: ModeKind,
+ default_mode_request_user_input: bool,
+) -> bool {
+ match tool_name {
+ QUESTION_TOOL_NAME => question_is_available(mode),
+ _ => request_user_input_is_available(mode, default_mode_request_user_input),
+ }
+}
+
+fn question_options_policy(tool_name: &str) -> QuestionOptionsPolicy {
+ match tool_name {
+ QUESTION_TOOL_NAME => QuestionOptionsPolicy::AllowFreeform,
+ _ => QuestionOptionsPolicy::RequireOptions,
+ }
+}
+
pub fn normalize_request_user_input_args(
+ args: RequestUserInputArgs,
+) -> Result {
+ normalize_request_user_input_args_for_tool(REQUEST_USER_INPUT_TOOL_NAME, args)
+}
+
+pub fn normalize_request_user_input_args_for_tool(
+ tool_name: &str,
mut args: RequestUserInputArgs,
) -> Result {
- let missing_options = args
- .questions
- .iter()
- .any(|question| question.options.as_ref().is_none_or(Vec::is_empty));
- if missing_options {
- return Err("request_user_input requires non-empty options for every question".to_string());
+ for question in &mut args.questions {
+ if question.options.as_ref().is_some_and(Vec::is_empty) {
+ question.options = None;
+ }
+ if question
+ .options
+ .as_ref()
+ .is_some_and(|options| !options.is_empty())
+ {
+ question.is_other = true;
+ }
}
- for question in &mut args.questions {
- question.is_other = true;
+ if question_options_policy(tool_name) == QuestionOptionsPolicy::RequireOptions
+ && args
+ .questions
+ .iter()
+ .any(|question| question.options.as_ref().is_none_or(Vec::is_empty))
+ {
+ return Err("request_user_input requires non-empty options for every question".to_string());
}
Ok(args)
}
pub fn request_user_input_tool_description(default_mode_request_user_input: bool) -> String {
- let allowed_modes = format_allowed_modes(default_mode_request_user_input);
- format!(
- "Request user input for one to three short questions and wait for the response. This tool is only available in {allowed_modes}."
+ interactive_question_tool_description(
+ REQUEST_USER_INPUT_TOOL_NAME,
+ "Request user input for one to three short questions and wait for the response.",
+ default_mode_request_user_input,
+ )
+}
+
+pub fn question_tool_description(default_mode_request_user_input: bool) -> String {
+ interactive_question_tool_description(
+ QUESTION_TOOL_NAME,
+ "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically.",
+ default_mode_request_user_input,
)
}
@@ -138,10 +241,19 @@ fn request_user_input_is_available(mode: ModeKind, default_mode_request_user_inp
|| (default_mode_request_user_input && mode == ModeKind::Default)
}
-fn format_allowed_modes(default_mode_request_user_input: bool) -> String {
+fn interactive_question_tool_description(
+ tool_name: &str,
+ tool_description: &str,
+ default_mode_request_user_input: bool,
+) -> String {
+ let allowed_modes = format_allowed_modes(tool_name, default_mode_request_user_input);
+ format!("{tool_description} This tool is only available in {allowed_modes}.")
+}
+
+fn format_allowed_modes(tool_name: &str, default_mode_request_user_input: bool) -> String {
let mode_names: Vec<&str> = TUI_VISIBLE_COLLABORATION_MODES
.into_iter()
- .filter(|mode| request_user_input_is_available(*mode, default_mode_request_user_input))
+ .filter(|mode| tool_is_available(tool_name, *mode, default_mode_request_user_input))
.map(ModeKind::display_name)
.collect();
diff --git a/codex-rs/tools/src/request_user_input_tool_tests.rs b/codex-rs/tools/src/request_user_input_tool_tests.rs
index e7a305f86..17379d774 100644
--- a/codex-rs/tools/src/request_user_input_tool_tests.rs
+++ b/codex-rs/tools/src/request_user_input_tool_tests.rs
@@ -1,14 +1,116 @@
use super::*;
use codex_protocol::config_types::ModeKind;
+use codex_protocol::request_user_input::RequestUserInputArgs;
+use codex_protocol::request_user_input::RequestUserInputQuestion;
+use codex_protocol::request_user_input::RequestUserInputQuestionOption;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
+#[test]
+fn question_tool_includes_questions_schema() {
+ assert_eq!(
+ create_question_tool("Ask the user for details.".to_string()),
+ ToolSpec::Function(ResponsesApiTool {
+ name: QUESTION_TOOL_NAME.to_string(),
+ description: "Ask the user for details.".to_string(),
+ strict: false,
+ defer_loading: None,
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "questions".to_string(),
+ JsonSchema::Array {
+ description: Some(
+ "Questions to show the user. There is no fixed maximum; use as many as needed for the form."
+ .to_string(),
+ ),
+ items: Box::new(JsonSchema::Object {
+ properties: BTreeMap::from([
+ (
+ "header".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Short header label shown in the UI (12 or fewer chars)."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "id".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Stable identifier for mapping answers (snake_case)."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "options".to_string(),
+ JsonSchema::Array {
+ description: Some(
+ "Optional mutually exclusive choices for this question. Omit this field for a freeform text answer. When provided, put the recommended option first and do not include an \"Other\" option; the client can collect additional notes separately."
+ .to_string(),
+ ),
+ items: Box::new(JsonSchema::Object {
+ properties: BTreeMap::from([
+ (
+ "description".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "One short sentence explaining impact/tradeoff if selected."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "label".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "User-facing label (1-5 words)."
+ .to_string(),
+ ),
+ },
+ ),
+ ]),
+ required: Some(vec![
+ "label".to_string(),
+ "description".to_string(),
+ ]),
+ additional_properties: Some(false.into()),
+ }),
+ },
+ ),
+ (
+ "question".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Prompt shown to the user for this field.".to_string(),
+ ),
+ },
+ ),
+ ]),
+ required: Some(vec![
+ "id".to_string(),
+ "header".to_string(),
+ "question".to_string(),
+ ]),
+ additional_properties: Some(false.into()),
+ }),
+ },
+ )]),
+ required: Some(vec!["questions".to_string()]),
+ additional_properties: Some(false.into()),
+ },
+ output_schema: None,
+ })
+ );
+}
+
#[test]
fn request_user_input_tool_includes_questions_schema() {
assert_eq!(
create_request_user_input_tool("Ask the user to choose.".to_string()),
ToolSpec::Function(ResponsesApiTool {
- name: "request_user_input".to_string(),
+ name: REQUEST_USER_INPUT_TOOL_NAME.to_string(),
description: "Ask the user to choose.".to_string(),
strict: false,
defer_loading: None,
@@ -102,6 +204,20 @@ fn request_user_input_tool_includes_questions_schema() {
);
}
+#[test]
+fn question_unavailable_messages_respect_mode_rules() {
+ assert_eq!(question_unavailable_message(ModeKind::Plan), None);
+ assert_eq!(question_unavailable_message(ModeKind::Default), None);
+ assert_eq!(
+ question_unavailable_message(ModeKind::Execute),
+ Some("question is unavailable in Execute mode".to_string())
+ );
+ assert_eq!(
+ question_unavailable_message(ModeKind::PairProgramming),
+ Some("question is unavailable in Pair Programming mode".to_string())
+ );
+}
+
#[test]
fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() {
assert_eq!(
@@ -141,6 +257,18 @@ fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() {
);
}
+#[test]
+fn question_tool_description_mentions_available_modes() {
+ assert_eq!(
+ question_tool_description(/*default_mode_request_user_input*/ false),
+ "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically. This tool is only available in Default or Plan mode.".to_string()
+ );
+ assert_eq!(
+ question_tool_description(/*default_mode_request_user_input*/ true),
+ "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically. This tool is only available in Default or Plan mode.".to_string()
+ );
+}
+
#[test]
fn request_user_input_tool_description_mentions_available_modes() {
assert_eq!(
@@ -152,3 +280,95 @@ fn request_user_input_tool_description_mentions_available_modes() {
"Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string()
);
}
+
+#[test]
+fn normalize_question_args_allows_freeform_questions() {
+ let args = RequestUserInputArgs {
+ questions: vec![RequestUserInputQuestion {
+ id: "details".to_string(),
+ header: "Details".to_string(),
+ question: "What changed?".to_string(),
+ is_other: false,
+ is_secret: false,
+ options: None,
+ }],
+ };
+
+ assert_eq!(
+ normalize_request_user_input_args_for_tool(QUESTION_TOOL_NAME, args.clone()),
+ Ok(args)
+ );
+}
+
+#[test]
+fn normalize_question_args_marks_multiple_choice_entries_as_other() {
+ let args = RequestUserInputArgs {
+ questions: vec![
+ RequestUserInputQuestion {
+ id: "details".to_string(),
+ header: "Details".to_string(),
+ question: "What changed?".to_string(),
+ is_other: false,
+ is_secret: false,
+ options: Some(Vec::new()),
+ },
+ RequestUserInputQuestion {
+ id: "confirm".to_string(),
+ header: "Confirm".to_string(),
+ question: "Continue?".to_string(),
+ is_other: false,
+ is_secret: false,
+ options: Some(vec![RequestUserInputQuestionOption {
+ label: "Yes".to_string(),
+ description: "Keep going.".to_string(),
+ }]),
+ },
+ ],
+ };
+
+ assert_eq!(
+ normalize_request_user_input_args_for_tool(QUESTION_TOOL_NAME, args),
+ Ok(RequestUserInputArgs {
+ questions: vec![
+ RequestUserInputQuestion {
+ id: "details".to_string(),
+ header: "Details".to_string(),
+ question: "What changed?".to_string(),
+ is_other: false,
+ is_secret: false,
+ options: None,
+ },
+ RequestUserInputQuestion {
+ id: "confirm".to_string(),
+ header: "Confirm".to_string(),
+ question: "Continue?".to_string(),
+ is_other: true,
+ is_secret: false,
+ options: Some(vec![RequestUserInputQuestionOption {
+ label: "Yes".to_string(),
+ description: "Keep going.".to_string(),
+ }]),
+ },
+ ],
+ })
+ );
+}
+
+#[test]
+fn normalize_request_user_input_args_requires_options() {
+ let args = RequestUserInputArgs {
+ questions: vec![RequestUserInputQuestion {
+ id: "confirm".to_string(),
+ header: "Confirm".to_string(),
+ question: "Continue?".to_string(),
+ is_other: false,
+ is_secret: false,
+ options: None,
+ }],
+ };
+
+ assert_eq!(
+ normalize_request_user_input_args(args),
+ Err("request_user_input requires non-empty options for every question".to_string())
+ );
+}
diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs
index cc706f24f..def3718c6 100644
--- a/codex-rs/tools/src/tool_registry_plan.rs
+++ b/codex-rs/tools/src/tool_registry_plan.rs
@@ -1,4 +1,5 @@
use crate::CommandToolOptions;
+use crate::QUESTION_TOOL_NAME;
use crate::REQUEST_USER_INPUT_TOOL_NAME;
use crate::ShellToolOptions;
use crate::SpawnAgentToolOptions;
@@ -23,6 +24,7 @@ use crate::create_close_agent_tool_v2;
use crate::create_code_mode_tool;
use crate::create_exec_command_tool;
use crate::create_followup_task_tool;
+use crate::create_grep_files_tool;
use crate::create_image_generation_tool;
use crate::create_js_repl_reset_tool;
use crate::create_js_repl_tool;
@@ -31,6 +33,8 @@ use crate::create_list_dir_tool;
use crate::create_list_mcp_resource_templates_tool;
use crate::create_list_mcp_resources_tool;
use crate::create_local_shell_tool;
+use crate::create_question_tool;
+use crate::create_read_file_tool;
use crate::create_read_mcp_resource_tool;
use crate::create_report_agent_job_result_tool;
use crate::create_request_permissions_tool;
@@ -55,6 +59,7 @@ use crate::create_web_search_tool;
use crate::create_write_stdin_tool;
use crate::dynamic_tool_to_responses_api_tool;
use crate::mcp_tool_to_responses_api_tool;
+use crate::question_tool_description;
use crate::request_permissions_tool_description;
use crate::request_user_input_tool_description;
use crate::tool_registry_plan_types::agent_type_description;
@@ -205,6 +210,14 @@ pub fn build_tool_registry_plan(
}
if config.request_user_input {
+ plan.push_spec(
+ create_question_tool(question_tool_description(
+ config.default_mode_request_user_input,
+ )),
+ /*supports_parallel_tool_calls*/ false,
+ config.code_mode_enabled,
+ );
+ plan.register_handler(QUESTION_TOOL_NAME, ToolHandlerKind::RequestUserInput);
plan.push_spec(
create_request_user_input_tool(request_user_input_tool_description(
config.default_mode_request_user_input,
@@ -285,6 +298,32 @@ pub fn build_tool_registry_plan(
plan.register_handler("apply_patch", ToolHandlerKind::ApplyPatch);
}
+ if config
+ .experimental_supported_tools
+ .iter()
+ .any(|tool| tool == "grep_files")
+ {
+ plan.push_spec(
+ create_grep_files_tool(),
+ /*supports_parallel_tool_calls*/ true,
+ config.code_mode_enabled,
+ );
+ plan.register_handler("grep_files", ToolHandlerKind::GrepFiles);
+ }
+
+ if config
+ .experimental_supported_tools
+ .iter()
+ .any(|tool| tool == "read_file")
+ {
+ plan.push_spec(
+ create_read_file_tool(),
+ /*supports_parallel_tool_calls*/ true,
+ config.code_mode_enabled,
+ );
+ plan.register_handler("read_file", ToolHandlerKind::ReadFile);
+ }
+
if config
.experimental_supported_tools
.iter()
diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs
index fd7ab1007..7aeb60853 100644
--- a/codex-rs/tools/src/tool_registry_plan_tests.rs
+++ b/codex-rs/tools/src/tool_registry_plan_tests.rs
@@ -81,6 +81,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
}),
create_write_stdin_tool(),
create_update_plan_tool(),
+ question_tool_spec(/*default_mode_request_user_input*/ false),
request_user_input_tool_spec(/*default_mode_request_user_input*/ false),
create_apply_patch_freeform_tool(),
ToolSpec::WebSearch {
@@ -176,6 +177,7 @@ fn test_build_specs_collab_tools_enabled() {
panic!("spawn_agent should use object params");
};
assert!(properties.contains_key("fork_context"));
+ assert!(properties.contains_key("cwd"));
assert!(!properties.contains_key("fork_turns"));
}
@@ -234,6 +236,7 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("fork_turns"));
+ assert!(properties.contains_key("cwd"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
@@ -491,11 +494,12 @@ fn test_build_specs_agent_job_worker_tools_enabled() {
"report_agent_job_result",
],
);
+ assert_lacks_tool_name(&tools, QUESTION_TOOL_NAME);
assert_lacks_tool_name(&tools, "request_user_input");
}
#[test]
-fn request_user_input_description_reflects_default_mode_feature_flag() {
+fn interactive_question_tools_reflect_default_mode_feature_flag() {
let model_info = model_info();
let mut features = Features::with_defaults();
let available_models = Vec::new();
@@ -514,6 +518,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() {
/*app_tools*/ None,
&[],
);
+ let question_tool = find_tool(&tools, QUESTION_TOOL_NAME);
+ assert_eq!(
+ question_tool.spec,
+ question_tool_spec(/*default_mode_request_user_input*/ false)
+ );
let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME);
assert_eq!(
request_user_input_tool.spec,
@@ -536,6 +545,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() {
/*app_tools*/ None,
&[],
);
+ let question_tool = find_tool(&tools, QUESTION_TOOL_NAME);
+ assert_eq!(
+ question_tool.spec,
+ question_tool_spec(/*default_mode_request_user_input*/ true)
+ );
let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME);
assert_eq!(
request_user_input_tool.spec,
@@ -1024,6 +1038,33 @@ fn test_test_model_info_includes_sync_tool() {
assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool"));
}
+#[test]
+fn test_model_info_includes_read_and_grep_tools() {
+ let mut model_info = model_info();
+ model_info.experimental_supported_tools =
+ vec!["read_file".to_string(), "grep_files".to_string()];
+ let features = Features::with_defaults();
+ let available_models = Vec::new();
+ let tools_config = ToolsConfig::new(&ToolsConfigParams {
+ model_info: &model_info,
+ available_models: &available_models,
+ features: &features,
+ web_search_mode: Some(WebSearchMode::Cached),
+ session_source: SessionSource::Cli,
+ sandbox_policy: &SandboxPolicy::DangerFullAccess,
+ windows_sandbox_level: WindowsSandboxLevel::Disabled,
+ });
+ let (tools, _) = build_specs(
+ &tools_config,
+ /*mcp_tools*/ None,
+ /*app_tools*/ None,
+ &[],
+ );
+
+ assert!(tools.iter().any(|tool| tool.name() == "read_file"));
+ assert!(tools.iter().any(|tool| tool.name() == "grep_files"));
+}
+
#[test]
fn test_build_specs_mcp_tools_converted() {
let model_info = model_info();
@@ -1810,6 +1851,10 @@ fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) {
);
}
+fn question_tool_spec(default_mode_request_user_input: bool) -> ToolSpec {
+ create_question_tool(question_tool_description(default_mode_request_user_input))
+}
+
fn request_user_input_tool_spec(default_mode_request_user_input: bool) -> ToolSpec {
create_request_user_input_tool(request_user_input_tool_description(
default_mode_request_user_input,
diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs
index d15cf15d5..7fed3b9e9 100644
--- a/codex-rs/tools/src/tool_registry_plan_types.rs
+++ b/codex-rs/tools/src/tool_registry_plan_types.rs
@@ -18,6 +18,7 @@ pub enum ToolHandlerKind {
CodeModeWait,
DynamicTool,
FollowupTaskV2,
+ GrepFiles,
JsRepl,
JsReplReset,
ListAgentsV2,
@@ -25,6 +26,7 @@ pub enum ToolHandlerKind {
Mcp,
McpResource,
Plan,
+ ReadFile,
RequestPermissions,
RequestUserInput,
ResumeAgentV1,
diff --git a/codex-rs/tools/src/utility_tool.rs b/codex-rs/tools/src/utility_tool.rs
index dca9d312f..c64d8b9c5 100644
--- a/codex-rs/tools/src/utility_tool.rs
+++ b/codex-rs/tools/src/utility_tool.rs
@@ -51,6 +51,164 @@ pub fn create_list_dir_tool() -> ToolSpec {
})
}
+pub fn create_grep_files_tool() -> ToolSpec {
+ let properties = BTreeMap::from([
+ (
+ "pattern".to_string(),
+ JsonSchema::String {
+ description: Some("Regular expression pattern to search for.".to_string()),
+ },
+ ),
+ (
+ "include".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Optional glob that limits which files are searched (e.g. \"*.rs\" or \
+ \"*.{ts,tsx}\")."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "path".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Directory or file path to search. Defaults to the session's working directory."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "limit".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "Maximum number of file paths to return (defaults to 100).".to_string(),
+ ),
+ },
+ ),
+ ]);
+
+ ToolSpec::Function(ResponsesApiTool {
+ name: "grep_files".to_string(),
+ description: "Finds files whose contents match the pattern and lists them by modification \
+ time."
+ .to_string(),
+ strict: false,
+ defer_loading: None,
+ parameters: JsonSchema::Object {
+ properties,
+ required: Some(vec!["pattern".to_string()]),
+ additional_properties: Some(false.into()),
+ },
+ output_schema: None,
+ })
+}
+
+pub fn create_read_file_tool() -> ToolSpec {
+ let indentation_properties = BTreeMap::from([
+ (
+ "anchor_line".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "Anchor line to center the indentation lookup on (defaults to offset)."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "max_levels".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "How many parent indentation levels (smaller indents) to include.".to_string(),
+ ),
+ },
+ ),
+ (
+ "include_siblings".to_string(),
+ JsonSchema::Boolean {
+ description: Some(
+ "When true, include additional blocks that share the anchor indentation."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "include_header".to_string(),
+ JsonSchema::Boolean {
+ description: Some(
+ "Include doc comments or attributes directly above the selected block."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "max_lines".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "Hard cap on the number of lines returned when using indentation mode."
+ .to_string(),
+ ),
+ },
+ ),
+ ]);
+
+ let properties = BTreeMap::from([
+ (
+ "file_path".to_string(),
+ JsonSchema::String {
+ description: Some("Absolute path to the file".to_string()),
+ },
+ ),
+ (
+ "offset".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "The line number to start reading from. Must be 1 or greater.".to_string(),
+ ),
+ },
+ ),
+ (
+ "limit".to_string(),
+ JsonSchema::Number {
+ description: Some("The maximum number of lines to return.".to_string()),
+ },
+ ),
+ (
+ "mode".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \
+ to expand around an anchor line."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "indentation".to_string(),
+ JsonSchema::Object {
+ properties: indentation_properties,
+ required: None,
+ additional_properties: Some(false.into()),
+ },
+ ),
+ ]);
+
+ ToolSpec::Function(ResponsesApiTool {
+ name: "read_file".to_string(),
+ description:
+ "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes."
+ .to_string(),
+ strict: false,
+ defer_loading: None,
+ parameters: JsonSchema::Object {
+ properties,
+ required: Some(vec!["file_path".to_string()]),
+ additional_properties: Some(false.into()),
+ },
+ output_schema: None,
+ })
+}
+
pub fn create_test_sync_tool() -> ToolSpec {
let barrier_properties = BTreeMap::from([
(
diff --git a/codex-rs/tools/src/utility_tool_tests.rs b/codex-rs/tools/src/utility_tool_tests.rs
index a8ea6777f..9e116b189 100644
--- a/codex-rs/tools/src/utility_tool_tests.rs
+++ b/codex-rs/tools/src/utility_tool_tests.rs
@@ -58,6 +58,173 @@ fn list_dir_tool_matches_expected_spec() {
);
}
+#[test]
+fn grep_files_tool_matches_expected_spec() {
+ assert_eq!(
+ create_grep_files_tool(),
+ ToolSpec::Function(ResponsesApiTool {
+ name: "grep_files".to_string(),
+ description: "Finds files whose contents match the pattern and lists them by modification \
+ time."
+ .to_string(),
+ strict: false,
+ defer_loading: None,
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([
+ (
+ "include".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Optional glob that limits which files are searched (e.g. \"*.rs\" or \
+ \"*.{ts,tsx}\")."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "limit".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "Maximum number of file paths to return (defaults to 100)."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "path".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Directory or file path to search. Defaults to the session's working directory."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "pattern".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Regular expression pattern to search for.".to_string(),
+ ),
+ },
+ ),
+ ]),
+ required: Some(vec!["pattern".to_string()]),
+ additional_properties: Some(false.into()),
+ },
+ output_schema: None,
+ })
+ );
+}
+
+#[test]
+fn read_file_tool_matches_expected_spec() {
+ assert_eq!(
+ create_read_file_tool(),
+ ToolSpec::Function(ResponsesApiTool {
+ name: "read_file".to_string(),
+ description:
+ "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes."
+ .to_string(),
+ strict: false,
+ defer_loading: None,
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([
+ (
+ "file_path".to_string(),
+ JsonSchema::String {
+ description: Some("Absolute path to the file".to_string()),
+ },
+ ),
+ (
+ "indentation".to_string(),
+ JsonSchema::Object {
+ properties: BTreeMap::from([
+ (
+ "anchor_line".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "Anchor line to center the indentation lookup on (defaults to offset)."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "include_header".to_string(),
+ JsonSchema::Boolean {
+ description: Some(
+ "Include doc comments or attributes directly above the selected block."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "include_siblings".to_string(),
+ JsonSchema::Boolean {
+ description: Some(
+ "When true, include additional blocks that share the anchor indentation."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "max_levels".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "How many parent indentation levels (smaller indents) to include."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "max_lines".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "Hard cap on the number of lines returned when using indentation mode."
+ .to_string(),
+ ),
+ },
+ ),
+ ]),
+ required: None,
+ additional_properties: Some(false.into()),
+ },
+ ),
+ (
+ "limit".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "The maximum number of lines to return.".to_string(),
+ ),
+ },
+ ),
+ (
+ "mode".to_string(),
+ JsonSchema::String {
+ description: Some(
+ "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \
+ to expand around an anchor line."
+ .to_string(),
+ ),
+ },
+ ),
+ (
+ "offset".to_string(),
+ JsonSchema::Number {
+ description: Some(
+ "The line number to start reading from. Must be 1 or greater."
+ .to_string(),
+ ),
+ },
+ ),
+ ]),
+ required: Some(vec!["file_path".to_string()]),
+ additional_properties: Some(false.into()),
+ },
+ output_schema: None,
+ })
+ );
+}
+
#[test]
fn test_sync_tool_matches_expected_spec() {
assert_eq!(
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 696ab503b..3ace51038 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -30,6 +30,7 @@ codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-chatgpt = { workspace = true }
+codex-clawbot = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
@@ -52,6 +53,7 @@ codex-utils-cli = { workspace = true }
codex-utils-elapsed = { workspace = true }
codex-utils-fuzzy-match = { workspace = true }
codex-utils-oss = { workspace = true }
+codex-utils-rustls-provider = { workspace = true }
codex-utils-sandbox-summary = { workspace = true }
codex-utils-sleep-inhibitor = { workspace = true }
codex-utils-string = { workspace = true }
@@ -62,8 +64,11 @@ diffy = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] }
+humantime = "2"
+indexmap = { workspace = true, features = ["serde"] }
itertools = { workspace = true }
lazy_static = { workspace = true }
+notify = { workspace = true }
pathdiff = { workspace = true }
pulldown-cmark = { workspace = true }
rand = { workspace = true }
@@ -79,6 +84,7 @@ reqwest = { workspace = true, features = ["json"] }
rmcp = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
+serde_yaml = { workspace = true }
shlex = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 4feefcac9..146d929fe 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -5,6 +5,7 @@ use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event::FeedbackCategory;
use crate::app_event::RealtimeAudioDeviceKind;
+use crate::app_event::RuntimeProfileTarget;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
@@ -25,6 +26,11 @@ use crate::chatwidget::ReplayKind;
use crate::chatwidget::ThreadInputState;
use crate::cwd_prompt::CwdPromptAction;
use crate::diff_render::DiffSummary;
+use crate::display_preferences::DisplayPreferences;
+use crate::display_preferences::display_preference_edit;
+use crate::display_preferences::set_display_preference_in_config;
+use crate::display_preferences_menu::DISPLAY_PREFERENCES_SELECTION_VIEW_ID;
+use crate::display_preferences_menu::display_preferences_panel_params;
use crate::exec_command::split_command_string;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::external_editor;
@@ -42,6 +48,7 @@ use crate::multi_agents::format_agent_picker_item_name;
use crate::multi_agents::next_agent_shortcut_matches;
use crate::multi_agents::previous_agent_shortcut_matches;
use crate::pager_overlay::Overlay;
+use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH;
use crate::read_session_model;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
@@ -82,6 +89,11 @@ use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStatus;
+use codex_clawbot::PendingClawbotTurn;
+#[cfg(test)]
+use codex_clawbot::ProviderOutboundReaction;
+#[cfg(test)]
+use codex_clawbot::ProviderOutboundTextMessage;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_core::config::Config;
@@ -132,6 +144,7 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::collections::BTreeMap;
use std::collections::HashMap;
+use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;
@@ -148,22 +161,53 @@ use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::unbounded_channel;
use tokio::task::JoinHandle;
+#[cfg(test)]
+use tokio_util::sync::CancellationToken;
use toml::Value as TomlValue;
use uuid::Uuid;
mod agent_navigation;
mod app_server_adapter;
mod app_server_requests;
+mod btw;
+mod clawbot;
+mod clawbot_controls;
+mod jump_navigation;
+mod key_chord;
mod loaded_threads;
mod pending_interactive_replay;
+mod profile_management;
+mod thread_menu;
+mod workflow_controls;
+mod workflow_definition;
+mod workflow_editor;
+mod workflow_file_watch;
+mod workflow_history;
+pub(crate) mod workflow_runtime;
+mod workflow_scheduler;
+mod workflow_yaml;
use self::agent_navigation::AgentNavigationDirection;
use self::agent_navigation::AgentNavigationState;
use self::app_server_requests::PendingAppServerRequests;
+use self::btw::BtwSessionState;
+use self::key_chord::KeyChordAction;
+use self::key_chord::KeyChordResolution;
+use self::key_chord::KeyChordState;
use self::loaded_threads::find_loaded_subagent_threads_for_primary;
use self::pending_interactive_replay::PendingInteractiveReplayState;
+use self::workflow_file_watch::WorkflowFileWatchState;
+use self::workflow_history::WorkflowHistoryState;
+use self::workflow_scheduler::WorkflowSchedulerState;
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
+const PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
+
+#[derive(Clone, Copy)]
+enum ThreadLivenessRefreshMode {
+ Picker,
+ Selection,
+}
enum ThreadInteractiveRequest {
Approval(ApprovalRequest),
@@ -236,6 +280,36 @@ fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[Str
}
}
+fn workflow_after_turn_last_agent_message(
+ primary_thread_id: Option,
+ thread_id: ThreadId,
+ notification: &ServerNotification,
+) -> Option> {
+ if primary_thread_id != Some(thread_id) {
+ return None;
+ }
+ let ServerNotification::TurnCompleted(notification) = notification else {
+ return None;
+ };
+ if !matches!(
+ notification.turn.status,
+ TurnStatus::Completed | TurnStatus::Failed
+ ) {
+ return None;
+ }
+ Some(last_agent_message_for_turn(¬ification.turn))
+}
+
+fn last_agent_message_for_turn(turn: &Turn) -> Option {
+ turn.items.iter().fold(None, |_, item| match item {
+ ThreadItem::AgentMessage { text, .. } => {
+ let trimmed = text.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
+ }
+ _ => None,
+ })
+}
+
fn default_exec_approval_decisions(
network_approval_context: Option<&codex_protocol::protocol::NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<&codex_protocol::approvals::ExecPolicyAmendment>,
@@ -270,6 +344,30 @@ fn guardian_approvals_mode() -> GuardianApprovalsMode {
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
}
}
+
+fn should_respawn_with_yolo(config: &Config) -> bool {
+ config.permissions.approval_policy.value() == AskForApproval::Never
+ && *config.permissions.sandbox_policy.get() == SandboxPolicy::DangerFullAccess
+}
+
+#[cfg(unix)]
+fn spawn_respawn_signal_listener(app_event_tx: AppEventSender) -> std::io::Result<()> {
+ use tokio::signal::unix::SignalKind;
+
+ let mut signal = tokio::signal::unix::signal(SignalKind::user_defined1())?;
+ tokio::spawn(async move {
+ if signal.recv().await.is_some() {
+ app_event_tx.send(AppEvent::Exit(ExitMode::RespawnImmediate));
+ }
+ });
+ Ok(())
+}
+
+#[cfg(not(unix))]
+fn spawn_respawn_signal_listener(_app_event_tx: AppEventSender) -> std::io::Result<()> {
+ Ok(())
+}
+
/// Baseline cadence for periodic stream commit animation ticks.
///
/// Smooth-mode streaming drains one line per tick, so this interval controls
@@ -282,6 +380,7 @@ pub struct AppExitInfo {
pub thread_id: Option,
pub thread_name: Option,
pub update_action: Option,
+ pub respawn_with_yolo: bool,
pub exit_reason: ExitReason,
}
@@ -292,6 +391,7 @@ impl AppExitInfo {
thread_id: None,
thread_name: None,
update_action: None,
+ respawn_with_yolo: false,
exit_reason: ExitReason::Fatal(message.into()),
}
}
@@ -306,6 +406,7 @@ pub(crate) enum AppRunControl {
#[derive(Debug, Clone)]
pub enum ExitReason {
UserRequested,
+ RespawnRequested,
Fatal(String),
}
@@ -581,10 +682,10 @@ impl ThreadEventStore {
ServerNotification::TurnStarted(turn) => {
self.active_turn_id = Some(turn.turn.id.clone());
}
- ServerNotification::TurnCompleted(turn) => {
- if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) {
- self.active_turn_id = None;
- }
+ ServerNotification::TurnCompleted(turn)
+ if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) =>
+ {
+ self.active_turn_id = None;
}
ServerNotification::ThreadClosed(_) => {
self.active_turn_id = None;
@@ -926,6 +1027,7 @@ async fn handle_model_migration_prompt_if_needed(
thread_id: None,
thread_name: None,
update_action: None,
+ respawn_with_yolo: false,
exit_reason: ExitReason::UserRequested,
});
}
@@ -968,6 +1070,8 @@ pub(crate) struct App {
// Esc-backtracking state grouped
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
+ key_chord: KeyChordState,
+ display_preferences: DisplayPreferences,
/// When set, the next draw re-renders the transcript into terminal scrollback once.
///
/// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed
@@ -1002,6 +1106,19 @@ pub(crate) struct App {
primary_session_configured: Option,
pending_primary_events: VecDeque,
pending_app_server_requests: PendingAppServerRequests,
+ workflow_thread_notification_channels: workflow_runtime::WorkflowThreadNotificationChannels,
+ workflow_file_watch: Option,
+ workflow_scheduler: WorkflowSchedulerState,
+ workflow_history: WorkflowHistoryState,
+ btw_session: Option,
+ btw_closing_thread_ids: HashSet,
+ clawbot_workspace_root: Option,
+ clawbot_provider_task: Option>,
+ clawbot_pending_turns: HashMap>,
+ #[cfg(test)]
+ clawbot_outbound_messages: Vec,
+ #[cfg(test)]
+ clawbot_outbound_reactions: Vec,
}
#[derive(Default)]
@@ -1079,6 +1196,7 @@ impl App {
) -> crate::chatwidget::ChatWidgetInit {
crate::chatwidget::ChatWidgetInit {
config: cfg,
+ display_preferences: self.display_preferences.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
// Fork/resume bootstraps here don't carry any prefilled message content.
@@ -1116,7 +1234,9 @@ impl App {
.rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf())
.await?;
self.apply_runtime_policy_overrides(&mut config);
+ self.active_profile = config.active_profile.clone();
self.config = config;
+ self.display_preferences.sync_from_config(&self.config);
self.chat_widget.sync_plugin_mentions_config(&self.config);
Ok(())
}
@@ -1131,6 +1251,99 @@ impl App {
}
}
+ async fn apply_runtime_config_change(
+ &mut self,
+ tui: &mut tui::Tui,
+ app_server: &mut AppServerSession,
+ next_config: Config,
+ reload_live_thread: bool,
+ ) -> std::result::Result<(), String> {
+ if !reload_live_thread || self.chat_widget.thread_id().is_none() {
+ self.active_profile = next_config.active_profile.clone();
+ self.config = next_config;
+ self.display_preferences.sync_from_config(&self.config);
+ self.chat_widget
+ .sync_config_for_profile_switch(&self.config);
+ tui.set_notification_method(self.config.tui_notification_method);
+ self.file_search
+ .update_search_dir(self.config.cwd.to_path_buf());
+ return Ok(());
+ }
+
+ if self.chat_widget.is_task_running() {
+ return Err("Cannot switch API profile while a task is in progress.".to_string());
+ }
+
+ let input_state = self.chat_widget.capture_thread_input_state();
+ let can_resume_live_thread = self
+ .chat_widget
+ .rollout_path()
+ .as_ref()
+ .is_some_and(|path| path.exists());
+ let thread_id = self
+ .chat_widget
+ .thread_id()
+ .ok_or_else(|| "No active thread to reload after switching profiles.".to_string())?;
+ self.close_active_thread_for_profile_reload(app_server, thread_id)
+ .await?;
+ let next_thread = if can_resume_live_thread {
+ app_server
+ .resume_thread(next_config.clone(), thread_id)
+ .await
+ .map_err(|err| {
+ format!("Failed to reload current session after switching profiles: {err}")
+ })?
+ } else {
+ app_server.start_thread(&next_config).await.map_err(|err| {
+ format!("Failed to start a fresh session after switching profiles: {err}")
+ })?
+ };
+ self.active_profile = next_config.active_profile.clone();
+ self.config = next_config;
+ self.display_preferences.sync_from_config(&self.config);
+ tui.set_notification_method(self.config.tui_notification_method);
+ self.file_search
+ .update_search_dir(self.config.cwd.to_path_buf());
+ self.replace_chat_widget_with_app_server_thread(tui, app_server, next_thread)
+ .await
+ .map_err(|err| err.to_string())?;
+ self.chat_widget.restore_thread_input_state(input_state);
+ Ok(())
+ }
+
+ async fn reload_user_config_for_app_server_runtime(
+ &mut self,
+ tui: &mut tui::Tui,
+ app_server: &mut AppServerSession,
+ ) -> Result<()> {
+ let current_cwd = self.chat_widget.config_ref().cwd.to_path_buf();
+ let mut next_config = self.rebuild_config_for_cwd(current_cwd).await?;
+ self.apply_runtime_policy_overrides(&mut next_config);
+
+ let reload_live_thread = Self::routed_profile_runtime_changed(&self.config, &next_config);
+ if reload_live_thread {
+ self.apply_runtime_config_change(
+ tui,
+ app_server,
+ next_config,
+ /*reload_live_thread*/ true,
+ )
+ .await
+ .map_err(|err| color_eyre::eyre::eyre!(err))?;
+ app_server.reload_user_config().await?;
+ } else {
+ app_server.reload_user_config().await?;
+ self.apply_runtime_config_change(
+ tui,
+ app_server,
+ next_config,
+ /*reload_live_thread*/ false,
+ )
+ .await
+ .map_err(|err| color_eyre::eyre::eyre!(err))?;
+ }
+ Ok(())
+ }
async fn rebuild_config_for_resume_or_fallback(
&mut self,
current_cwd: &Path,
@@ -1539,13 +1752,141 @@ impl App {
async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
if let Some(thread_id) = self.chat_widget.thread_id() {
+ let shutting_down_primary = self.primary_thread_id == Some(thread_id);
// Clear any in-flight rollback guard when switching threads.
self.backtrack.pending_rollback = None;
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
tracing::warn!("failed to unsubscribe thread {thread_id}: {err}");
}
self.abort_thread_event_listener(thread_id);
+ if shutting_down_primary {
+ let stopped_count = self.workflow_scheduler.stop_active_workflow_runs().await;
+ if stopped_count > 0 {
+ self.sync_background_workflow_status();
+ }
+ }
+ }
+ }
+
+ fn background_workflow_labels(&self) -> Vec {
+ self.workflow_scheduler.background_workflow_labels()
+ }
+
+ fn queued_trigger_labels(&self) -> Vec {
+ self.workflow_scheduler.queued_trigger_labels()
+ }
+
+ fn sync_background_workflow_status(&mut self) {
+ self.chat_widget.sync_background_workflow_status(
+ self.background_workflow_labels(),
+ self.queued_trigger_labels(),
+ );
+ self.refresh_workflow_controls_if_active();
+ }
+
+ fn insert_visible_history_cell(&mut self, tui: &mut tui::Tui, cell: Arc) {
+ if let Some(Overlay::Transcript(t)) = &mut self.overlay {
+ t.insert_cell(cell.clone());
+ tui.frame_requester().schedule_frame();
+ }
+ self.transcript_cells.push(cell.clone());
+ let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
+ if display.is_empty() {
+ return;
+ }
+ if !cell.is_stream_continuation() {
+ if self.has_emitted_history_lines {
+ display.insert(0, Line::from(""));
+ } else {
+ self.has_emitted_history_lines = true;
+ }
+ }
+ if self.overlay.is_some() {
+ self.deferred_history_lines.extend(display);
+ } else {
+ tui.insert_history_lines(display);
+ }
+ }
+
+ #[cfg(test)]
+ fn start_test_background_workflow_run(
+ &mut self,
+ workflow_name: String,
+ target_name: String,
+ is_trigger: bool,
+ ) -> String {
+ let run_id = self
+ .workflow_scheduler
+ .next_background_run_id(&workflow_name, &target_name);
+ let handle = tokio::spawn(async {
+ std::future::pending::<()>().await;
+ });
+ let target = if is_trigger {
+ workflow_runtime::BackgroundWorkflowRunTarget::Trigger {
+ workflow_name,
+ trigger_id: target_name,
+ phase_context: workflow_runtime::OwnedWorkflowPhaseContext::default(),
+ overlap_behavior: workflow_runtime::WorkflowTriggerOverlapBehavior::Queue,
+ }
+ } else {
+ workflow_runtime::BackgroundWorkflowRunTarget::Job {
+ workflow_name,
+ job_name: target_name,
+ }
+ };
+ self.workflow_scheduler.register_background_workflow_run(
+ run_id.clone(),
+ target,
+ CancellationToken::new(),
+ handle,
+ );
+ self.sync_background_workflow_status();
+ run_id
+ }
+
+ #[cfg(test)]
+ fn start_test_manual_workflow_trigger_run(
+ &mut self,
+ workflow_name: String,
+ trigger_id: String,
+ ) -> Option {
+ if self.workflow_scheduler.has_running_trigger_run() {
+ self.workflow_scheduler.enqueue_trigger_run(
+ workflow_name,
+ trigger_id,
+ workflow_runtime::OwnedWorkflowPhaseContext::default(),
+ );
+ self.sync_background_workflow_status();
+ None
+ } else {
+ Some(self.start_test_background_workflow_run(
+ workflow_name,
+ trigger_id,
+ /*is_trigger*/ true,
+ ))
+ }
+ }
+
+ #[cfg(test)]
+ async fn finish_test_background_workflow_run(&mut self, run_id: String) {
+ let Some(run) = self
+ .workflow_scheduler
+ .take_background_workflow_run(&run_id)
+ else {
+ return;
+ };
+ run.handle.abort();
+ let _ = run.handle.await;
+ if run.is_trigger
+ && let Some(next) = self.workflow_scheduler.dequeue_trigger_run()
+ {
+ self.start_test_background_workflow_run(
+ next.workflow_name,
+ next.trigger_id,
+ /*is_trigger*/ true,
+ );
}
+ self.sync_background_workflow_status();
}
fn abort_thread_event_listener(&mut self, thread_id: ThreadId) {
@@ -1824,6 +2165,7 @@ impl App {
async fn submit_active_thread_op(
&mut self,
+ tui: &mut tui::Tui,
app_server: &mut AppServerSession,
op: AppCommand,
) -> Result<()> {
@@ -1833,15 +2175,19 @@ impl App {
return Ok(());
};
- self.submit_thread_op(app_server, thread_id, op).await
+ self.submit_thread_op(tui, app_server, thread_id, op).await
}
async fn submit_thread_op(
&mut self,
+ tui: &mut tui::Tui,
app_server: &mut AppServerSession,
thread_id: ThreadId,
op: AppCommand,
) -> Result<()> {
+ let (op, workflow_cells) = self
+ .augment_primary_thread_op_with_before_turn_workflows(app_server, thread_id, op)
+ .await;
crate::session_log::log_outbound_op(&op);
if self.try_handle_local_history_op(thread_id, &op).await? {
@@ -1855,6 +2201,12 @@ impl App {
return Ok(());
}
+ if matches!(op.view(), AppCommandView::ReloadUserConfig) {
+ self.reload_user_config_for_app_server_runtime(tui, app_server)
+ .await?;
+ return Ok(());
+ }
+
if self
.try_submit_active_thread_op_via_app_server(app_server, thread_id, &op)
.await?
@@ -1863,6 +2215,11 @@ impl App {
self.note_thread_outbound_op(thread_id, &op).await;
self.refresh_pending_thread_approvals().await;
}
+ for cell in workflow_cells {
+ if let Some(visible_cell) = self.record_workflow_history_cell(thread_id, cell) {
+ self.insert_visible_history_cell(tui, visible_cell);
+ }
+ }
return Ok(());
}
@@ -2362,6 +2719,11 @@ impl App {
app_server
.thread_background_terminals_clean(thread_id)
.await?;
+ let stopped_workflow_runs =
+ self.workflow_scheduler.stop_active_workflow_runs().await;
+ if stopped_workflow_runs > 0 {
+ self.sync_background_workflow_status();
+ }
Ok(true)
}
AppCommandView::RealtimeConversationStart(params) => {
@@ -2718,6 +3080,7 @@ impl App {
self.chat_widget.handle_thread_session(session);
self.chat_widget
.replay_thread_turns(turns, ReplayKind::ResumeInitialMessages);
+ self.queue_workflow_history_replay_for_thread(thread_id);
let pending = std::mem::take(&mut self.pending_primary_events);
for pending_event in pending {
match pending_event {
@@ -2833,7 +3196,11 @@ impl App {
}
for thread_id in thread_ids {
if !self
- .refresh_agent_picker_thread_liveness(app_server, thread_id)
+ .refresh_agent_picker_thread_liveness(
+ app_server,
+ thread_id,
+ ThreadLivenessRefreshMode::Picker,
+ )
.await
{
continue;
@@ -2897,6 +3264,22 @@ impl App {
});
}
+ fn open_display_preferences_panel(&mut self) {
+ let initial_selected_idx = self
+ .chat_widget
+ .selected_index_for_active_view(DISPLAY_PREFERENCES_SELECTION_VIEW_ID);
+ if !self.chat_widget.replace_selection_view_if_active(
+ DISPLAY_PREFERENCES_SELECTION_VIEW_ID,
+ display_preferences_panel_params(&self.display_preferences, initial_selected_idx),
+ ) {
+ self.chat_widget
+ .show_selection_view(display_preferences_panel_params(
+ &self.display_preferences,
+ initial_selected_idx,
+ ));
+ }
+ }
+
fn is_terminal_thread_read_error(err: &color_eyre::Report) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("thread not loaded:"))
@@ -2909,6 +3292,18 @@ impl App {
Self::is_terminal_thread_read_error(err) || existing_is_closed.unwrap_or(false)
}
+ fn closed_state_for_thread_read_status(
+ status: &codex_app_server_protocol::ThreadStatus,
+ mode: ThreadLivenessRefreshMode,
+ ) -> bool {
+ match mode {
+ ThreadLivenessRefreshMode::Picker => {
+ matches!(status, codex_app_server_protocol::ThreadStatus::NotLoaded)
+ }
+ ThreadLivenessRefreshMode::Selection => false,
+ }
+ }
+
fn can_fallback_from_include_turns_error(err: &color_eyre::Report) -> bool {
err.chain().any(|cause| {
let message = cause.to_string();
@@ -2951,6 +3346,7 @@ impl App {
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
+ mode: ThreadLivenessRefreshMode,
) -> bool {
let existing_entry = self.agent_navigation.get(&thread_id).cloned();
let has_replay_channel = self.thread_event_channels.contains_key(&thread_id);
@@ -2971,17 +3367,29 @@ impl App {
.as_ref()
.and_then(|entry| entry.agent_role.clone())
}),
- matches!(
- thread.status,
- codex_app_server_protocol::ThreadStatus::NotLoaded
- ),
+ Self::closed_state_for_thread_read_status(&thread.status, mode),
);
true
}
Err(err) => {
if Self::is_terminal_thread_read_error(&err) && !has_replay_channel {
- self.agent_navigation.remove(thread_id);
- return false;
+ match mode {
+ ThreadLivenessRefreshMode::Picker => {
+ self.agent_navigation.remove(thread_id);
+ return false;
+ }
+ ThreadLivenessRefreshMode::Selection => {
+ if let Some(entry) = existing_entry {
+ self.upsert_agent_picker_thread(
+ thread_id,
+ entry.agent_nickname,
+ entry.agent_role,
+ /*is_closed*/ false,
+ );
+ }
+ return true;
+ }
+ }
}
let is_closed = Self::closed_state_for_thread_read_error(
&err,
@@ -3146,7 +3554,11 @@ impl App {
}
if !self
- .refresh_agent_picker_thread_liveness(app_server, thread_id)
+ .refresh_agent_picker_thread_liveness(
+ app_server,
+ thread_id,
+ ThreadLivenessRefreshMode::Selection,
+ )
.await
{
self.chat_widget
@@ -3222,7 +3634,7 @@ impl App {
};
self.chat_widget.add_info_message(message, /*hint*/ None);
}
- self.drain_active_thread_events(tui).await?;
+ self.drain_active_thread_events(tui, app_server).await?;
self.refresh_pending_thread_approvals().await;
Ok(())
@@ -3328,6 +3740,7 @@ impl App {
self.enqueue_primary_thread_session(started.session, started.turns)
.await?;
self.backfill_loaded_subagent_threads(app_server).await;
+ self.sync_clawbot_workspace(app_server).await;
Ok(())
}
@@ -3439,7 +3852,11 @@ impl App {
config
}
- async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> {
+ async fn drain_active_thread_events(
+ &mut self,
+ tui: &mut tui::Tui,
+ app_server: &AppServerSession,
+ ) -> Result<()> {
let Some(mut rx) = self.active_thread_rx.take() else {
return Ok(());
};
@@ -3447,7 +3864,32 @@ impl App {
let mut disconnected = false;
loop {
match rx.try_recv() {
- Ok(event) => self.handle_thread_event_now(event),
+ Ok(event) => {
+ let after_turn =
+ self.active_thread_id
+ .and_then(|active_thread_id| match &event {
+ ThreadBufferedEvent::Notification(notification) => {
+ workflow_after_turn_last_agent_message(
+ self.primary_thread_id,
+ active_thread_id,
+ notification,
+ )
+ }
+ _ => None,
+ });
+ self.handle_thread_event_now(event);
+ if let Some(last_agent_message) = after_turn {
+ for cell in self
+ .handle_primary_thread_turn_complete_for_workflows(
+ app_server,
+ last_agent_message,
+ )
+ .await
+ {
+ self.insert_visible_history_cell(tui, cell);
+ }
+ }
+ }
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
@@ -3513,6 +3955,9 @@ impl App {
for event in snapshot.events {
self.handle_thread_event_replay(event);
}
+ if let Some(thread_id) = self.active_thread_id {
+ self.queue_workflow_history_replay_for_thread(thread_id);
+ }
self.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ false);
self.chat_widget
@@ -3565,6 +4010,7 @@ impl App {
use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
+ spawn_respawn_signal_listener(app_event_tx.clone())?;
emit_project_config_warnings(&app_event_tx, &config);
emit_system_bwrap_warning(&app_event_tx, &config);
tui.set_notification_method(config.tui_notification_method);
@@ -3631,6 +4077,7 @@ impl App {
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
+ let display_preferences = DisplayPreferences::from_config(&config);
let enhanced_keys_supported = tui.enhanced_keys_supported();
let wait_for_initial_session_configured =
@@ -3643,6 +4090,7 @@ impl App {
.await;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
+ display_preferences: display_preferences.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_user_message: crate::chatwidget::create_initial_user_message(
@@ -3677,6 +4125,7 @@ impl App {
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
+ display_preferences: display_preferences.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_user_message: crate::chatwidget::create_initial_user_message(
@@ -3716,6 +4165,7 @@ impl App {
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
+ display_preferences: display_preferences.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_user_message: crate::chatwidget::create_initial_user_message(
@@ -3758,6 +4208,7 @@ impl App {
app_event_tx,
chat_widget,
config,
+ display_preferences,
active_profile,
cli_kv_overrides,
harness_overrides,
@@ -3773,6 +4224,7 @@ impl App {
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
backtrack: BacktrackState::default(),
+ key_chord: KeyChordState::default(),
backtrack_render_pending: false,
feedback: feedback.clone(),
feedback_audience,
@@ -3791,11 +4243,35 @@ impl App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
+ workflow_thread_notification_channels: Arc::new(
+ tokio::sync::Mutex::new(HashMap::new()),
+ ),
+ workflow_file_watch: None,
+ workflow_scheduler: WorkflowSchedulerState::default(),
+ workflow_history: WorkflowHistoryState::default(),
+ btw_session: None,
+ btw_closing_thread_ids: HashSet::new(),
+ clawbot_workspace_root: None,
+ clawbot_provider_task: None,
+ clawbot_pending_turns: HashMap::new(),
+ #[cfg(test)]
+ clawbot_outbound_messages: Vec::new(),
+ #[cfg(test)]
+ clawbot_outbound_reactions: Vec::new(),
};
+ match WorkflowFileWatchState::new(app.config.cwd.as_path(), app.app_event_tx.clone()) {
+ Ok(state) => {
+ app.workflow_file_watch = Some(state);
+ }
+ Err(err) => {
+ tracing::warn!("failed to start workflow file watcher: {err}");
+ }
+ }
if let Some(started) = initial_started_thread {
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
}
+ app.sync_clawbot_workspace(&mut app_server).await;
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
#[cfg(target_os = "windows")]
@@ -3917,6 +4393,7 @@ impl App {
}
}
};
+ app.abort_clawbot_provider_runtime();
if let Err(err) = app_server.shutdown().await {
tracing::warn!(error = %err, "failed to shut down embedded app server");
}
@@ -3938,6 +4415,7 @@ impl App {
thread_id: app.chat_widget.thread_id(),
thread_name: app.chat_widget.thread_name(),
update_action: app.pending_update_action,
+ respawn_with_yolo: should_respawn_with_yolo(&app.config),
exit_reason,
})
}
@@ -4046,8 +4524,9 @@ impl App {
match crate::resume_picker::run_resume_picker_with_app_server(
tui,
&self.config,
- /*show_all*/ false,
- /*include_non_interactive*/ false,
+ crate::resume_picker::SessionPickerOrder::LocalGroupFirst,
+ crate::resume_picker::SessionPickerProviderScope::AllProviders,
+ /*include_non_interactive*/ true,
picker_app_server,
)
.await?
@@ -4146,6 +4625,34 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
+ AppEvent::OpenDisplayPreferencesPanel => {
+ self.open_display_preferences_panel();
+ }
+ AppEvent::OpenProfileManagementPanel => {
+ self.open_profile_management_panel();
+ }
+ AppEvent::EditProfileFallbackConfig => {
+ self.edit_profile_fallback_config_from_ui(tui).await;
+ }
+ AppEvent::OpenThreadPanel => {
+ self.open_thread_panel();
+ }
+ AppEvent::OpenJumpToMessagePanel => {
+ self.open_jump_to_message_panel();
+ }
+ AppEvent::JumpToTranscriptCell { cell_index } => {
+ self.reset_backtrack_state();
+ self.backtrack.overlay_preview_active = false;
+ if !matches!(self.overlay, Some(Overlay::Transcript(_))) {
+ self.open_transcript_overlay(tui);
+ }
+ if let Some(Overlay::Transcript(overlay)) = &mut self.overlay
+ && cell_index < self.transcript_cells.len()
+ {
+ overlay.set_highlight_cell(Some(cell_index));
+ tui.frame_requester().schedule_frame();
+ }
+ }
AppEvent::ForkCurrentSession => {
self.session_telemetry.counter(
"codex.thread.fork",
@@ -4205,93 +4712,347 @@ impl App {
tui.frame_requester().schedule_frame();
}
+ AppEvent::UndoLastUserMessage => {
+ self.undo_last_user_message();
+ }
+ AppEvent::SwitchRuntimeProfile { target } => {
+ let is_default_target = matches!(&target, RuntimeProfileTarget::Default);
+ let target_profile = match &target {
+ RuntimeProfileTarget::Default => None,
+ RuntimeProfileTarget::Named(profile_id) => Some(profile_id.as_str()),
+ };
+ let target_label = target_profile.unwrap_or("default");
+ if let Err(err) = self
+ .switch_runtime_profile(tui, app_server, target_profile)
+ .await
+ {
+ self.chat_widget.add_error_message(format!(
+ "Failed to switch to profile `{target_label}`: {err}"
+ ));
+ } else if let Err(err) = self.profile_router_store().update(|state| {
+ state.set_runtime_active_profile(target_profile);
+ }) {
+ self.chat_widget.add_error_message(format!(
+ "Switched to profile `{target_label}`, but failed to persist {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}"
+ ));
+ } else if is_default_target {
+ self.chat_widget.add_info_message(
+ "Switched to the default config profile.".to_string(),
+ /*hint*/ None,
+ );
+ } else {
+ self.chat_widget.add_info_message(
+ format!("Switched to profile `{target_label}`."),
+ /*hint*/ None,
+ );
+ }
+ }
AppEvent::InsertHistoryCell(cell) => {
let cell: Arc = cell.into();
- if let Some(Overlay::Transcript(t)) = &mut self.overlay {
- t.insert_cell(cell.clone());
- tui.frame_requester().schedule_frame();
- }
- self.transcript_cells.push(cell.clone());
- let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
- if !display.is_empty() {
- // Only insert a separating blank line for new cells that are not
- // part of an ongoing stream. Streaming continuations should not
- // accrue extra blank lines between chunks.
- if !cell.is_stream_continuation() {
- if self.has_emitted_history_lines {
- display.insert(0, Line::from(""));
- } else {
- self.has_emitted_history_lines = true;
- }
- }
+ self.insert_visible_history_cell(tui, cell);
+ }
+ AppEvent::ReplayWorkflowHistory { thread_id } => {
+ if self.active_thread_id == Some(thread_id) {
+ let lines = self.replay_workflow_history_cells_for_thread(
+ thread_id,
+ tui.terminal.last_known_screen_size.width,
+ );
if self.overlay.is_some() {
- self.deferred_history_lines.extend(display);
- } else {
- tui.insert_history_lines(display);
+ self.deferred_history_lines.extend(lines);
+ } else if !lines.is_empty() {
+ tui.insert_history_lines(lines);
}
}
}
- AppEvent::ApplyThreadRollback { num_turns } => {
- if self.apply_non_pending_thread_rollback(num_turns) {
- tui.frame_requester().schedule_frame();
+ AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => {
+ let cells = self
+ .finish_background_workflow_run(app_server, run_id, *result)
+ .await;
+ for cell in cells {
+ self.insert_visible_history_cell(tui, cell);
}
}
- AppEvent::StartCommitAnimation => {
- if self
- .commit_anim_running
- .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
- .is_ok()
- {
- let tx = self.app_event_tx.clone();
- let running = self.commit_anim_running.clone();
- thread::spawn(move || {
- while running.load(Ordering::Relaxed) {
- thread::sleep(COMMIT_ANIMATION_TICK);
- tx.send(AppEvent::CommitTick);
- }
- });
+ AppEvent::StartBtwDiscussion { prompt } => {
+ self.start_btw_discussion(app_server, prompt).await;
+ }
+ AppEvent::BtwCompleted { thread_id, result } => {
+ let is_error = result.is_err();
+ self.finish_btw_discussion(thread_id, result);
+ if is_error {
+ self.close_btw_session(app_server).await;
}
}
- AppEvent::StopCommitAnimation => {
- self.commit_anim_running.store(false, Ordering::Release);
+ AppEvent::BtwInsertSummary => {
+ self.insert_btw_summary(app_server).await;
}
- AppEvent::CommitTick => {
- self.chat_widget.on_commit_tick();
+ AppEvent::BtwInsertFull => {
+ self.insert_btw_full(app_server).await;
}
- AppEvent::Exit(mode) => {
- return Ok(self.handle_exit_mode(app_server, mode).await);
+ AppEvent::BtwDiscard => {
+ self.discard_btw_session(app_server).await;
}
- AppEvent::FatalExitRequest(message) => {
- return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
+ AppEvent::RetryLastUserTurnWithProfileFallback {
+ action,
+ error_message,
+ } => {
+ self.retry_last_user_turn_with_profile_fallback(
+ tui,
+ app_server,
+ action,
+ error_message,
+ )
+ .await;
}
- AppEvent::CodexOp(op) => {
- self.submit_active_thread_op(app_server, op.into()).await?;
+ AppEvent::OpenWorkflowControls => {
+ self.open_workflow_controls_popup();
}
- AppEvent::SubmitThreadOp { thread_id, op } => {
- self.submit_thread_op(app_server, thread_id, op.into())
- .await?;
+ AppEvent::OpenWorkflowControlView { destination } => {
+ self.open_workflow_control_view(destination);
}
- AppEvent::ThreadHistoryEntryResponse { thread_id, event } => {
- self.enqueue_thread_history_entry_response(thread_id, event)
- .await?;
+ AppEvent::CreateDefaultWorkflowTemplate => {
+ self.create_default_workflow_template_from_ui(tui).await;
}
- AppEvent::DiffResult(text) => {
- // Clear the in-progress state in the bottom pane
- self.chat_widget.on_diff_complete();
- // Enter alternate screen using TUI helper and build pager lines
- let _ = tui.enter_alt_screen();
- let pager_lines: Vec> = if text.trim().is_empty() {
- vec!["No changes detected.".italic().into()]
- } else {
- text.lines().map(ansi_escape_line).collect()
- };
- self.overlay = Some(Overlay::new_static_with_lines(
- pager_lines,
- "D I F F".to_string(),
- ));
- tui.frame_requester().schedule_frame();
+ AppEvent::EditWorkflowFile {
+ workflow_path,
+ reopen,
+ } => {
+ self.edit_workflow_file_from_ui(tui, workflow_path, reopen)
+ .await;
}
- AppEvent::OpenAppLink {
+ AppEvent::ToggleWorkflowTriggerEnabled {
+ workflow_path,
+ trigger_id,
+ } => {
+ self.toggle_workflow_trigger_enabled_from_ui(workflow_path, trigger_id);
+ }
+ AppEvent::ToggleWorkflowJobEnabled {
+ workflow_path,
+ job_name,
+ } => {
+ self.toggle_workflow_job_enabled_from_ui(workflow_path, job_name);
+ }
+ AppEvent::CycleWorkflowJobContext {
+ workflow_path,
+ job_name,
+ } => {
+ self.cycle_workflow_job_context_from_ui(workflow_path, job_name);
+ }
+ AppEvent::CycleWorkflowJobResponse {
+ workflow_path,
+ job_name,
+ } => {
+ self.cycle_workflow_job_response_from_ui(workflow_path, job_name);
+ }
+ AppEvent::EditWorkflowJobField {
+ workflow_path,
+ job_name,
+ field,
+ } => {
+ self.edit_workflow_job_field_from_ui(tui, workflow_path, job_name, field)
+ .await;
+ }
+ AppEvent::SetWorkflowTriggerType {
+ workflow_path,
+ trigger_id,
+ trigger_type,
+ } => {
+ self.set_workflow_trigger_type_from_ui(workflow_path, trigger_id, trigger_type);
+ }
+ AppEvent::EditWorkflowTriggerField {
+ workflow_path,
+ trigger_id,
+ field,
+ } => {
+ self.edit_workflow_trigger_field_from_ui(tui, workflow_path, trigger_id, field)
+ .await;
+ }
+ AppEvent::WorkflowWorkspaceFilesChanged { changed_paths } => {
+ let relevant_paths = changed_paths
+ .into_iter()
+ .filter(|path| {
+ workflow_file_watch::is_relevant_workspace_change(
+ self.config.cwd.as_path(),
+ path.as_path(),
+ )
+ })
+ .collect::>();
+ if !relevant_paths.is_empty() {
+ let cells = self.handle_workspace_file_changes_for_workflows(
+ app_server,
+ relevant_paths.as_slice(),
+ );
+ for cell in cells {
+ self.insert_visible_history_cell(tui, cell);
+ }
+ }
+ }
+ AppEvent::StartManualWorkflowTrigger {
+ workflow_name,
+ trigger_id,
+ } => {
+ let cell = self.start_manual_workflow_trigger_from_ui(
+ app_server,
+ workflow_name,
+ trigger_id,
+ );
+ self.insert_visible_history_cell(tui, cell);
+ }
+ AppEvent::StartManualWorkflowJob {
+ workflow_name,
+ job_name,
+ } => {
+ let cell =
+ self.start_manual_workflow_job_from_ui(app_server, workflow_name, job_name);
+ self.insert_visible_history_cell(tui, cell);
+ }
+ AppEvent::ShowWorkflowBackgroundTasks => {
+ self.chat_widget.add_ps_output();
+ }
+ AppEvent::ClawbotProviderEvent { event } => {
+ if let Err(err) = self.handle_clawbot_provider_event(app_server, event).await {
+ tracing::warn!(error = %err, "failed to handle clawbot provider event");
+ self.chat_widget
+ .add_error_message(format!("Clawbot provider event failed: {err}"));
+ }
+ }
+ AppEvent::ClawbotTurnCompleted { thread_id, turn } => {
+ if let Err(err) = self
+ .handle_clawbot_turn_completed(app_server, thread_id, turn)
+ .await
+ {
+ tracing::warn!(error = %err, "failed to handle clawbot turn completion");
+ self.chat_widget
+ .add_error_message(format!("Clawbot turn forwarding failed: {err}"));
+ }
+ }
+ AppEvent::OpenClawbotManagement => {
+ self.open_clawbot_management_popup();
+ }
+ AppEvent::OpenClawbotFeishuConfigPrompt { field } => {
+ self.open_clawbot_feishu_config_prompt(field);
+ }
+ AppEvent::SaveClawbotFeishuConfigValue { field, value } => {
+ if let Err(err) = self.save_clawbot_feishu_config_value(field, value) {
+ self.chat_widget
+ .add_error_message(format!("Failed to save Clawbot config: {err}"));
+ }
+ }
+ AppEvent::OpenClawbotManualBindPrompt => {
+ self.open_clawbot_manual_bind_prompt();
+ }
+ AppEvent::SaveClawbotManualBindSessionId { session_id } => {
+ if let Err(err) = self
+ .bind_clawbot_session_to_current_thread(app_server, session_id)
+ .await
+ {
+ self.chat_widget
+ .add_error_message(format!("Failed to bind Clawbot session: {err}"));
+ }
+ }
+ AppEvent::ClawbotSetTurnMode { mode } => {
+ if let Err(err) = self.save_clawbot_turn_mode(mode) {
+ self.chat_widget
+ .add_error_message(format!("Failed to save Clawbot turn mode: {err}"));
+ }
+ }
+ AppEvent::ClawbotDisconnectCurrentThread => {
+ if let Err(err) = self.clawbot_disconnect_current_thread() {
+ self.chat_widget
+ .add_error_message(format!("Failed to disconnect Clawbot binding: {err}"));
+ }
+ }
+ AppEvent::ClawbotSetCurrentThreadForwarding { channel, enabled } => {
+ if let Err(err) = self.clawbot_set_current_thread_forwarding(channel, enabled) {
+ self.chat_widget
+ .add_error_message(format!("Failed to update Clawbot forwarding: {err}"));
+ }
+ }
+ AppEvent::ScanClawbotFeishuSessions => {
+ if let Err(err) = self.scan_clawbot_feishu_sessions().await {
+ self.chat_widget
+ .add_error_message(format!("Failed to scan Feishu sessions: {err}"));
+ }
+ }
+ AppEvent::ClearClawbotFeishuSessions => {
+ if let Err(err) = self.clear_clawbot_feishu_sessions() {
+ self.chat_widget.add_error_message(format!(
+ "Failed to clear unbound Feishu sessions: {err}"
+ ));
+ }
+ }
+ AppEvent::RetryClawbotFeishuConnection => {
+ if let Err(err) = self.retry_clawbot_feishu_connection() {
+ self.chat_widget.add_error_message(format!(
+ "Failed to restart Clawbot Feishu runtime: {err}"
+ ));
+ }
+ }
+ AppEvent::ApplyThreadRollback { num_turns } => {
+ if self.apply_non_pending_thread_rollback(num_turns) {
+ tui.frame_requester().schedule_frame();
+ }
+ }
+ AppEvent::StartCommitAnimation => {
+ if self
+ .commit_anim_running
+ .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
+ .is_ok()
+ {
+ let tx = self.app_event_tx.clone();
+ let running = self.commit_anim_running.clone();
+ thread::spawn(move || {
+ while running.load(Ordering::Relaxed) {
+ thread::sleep(COMMIT_ANIMATION_TICK);
+ tx.send(AppEvent::CommitTick);
+ }
+ });
+ }
+ }
+ AppEvent::StopCommitAnimation => {
+ self.commit_anim_running.store(false, Ordering::Release);
+ }
+ AppEvent::CommitTick => {
+ self.chat_widget.on_commit_tick();
+ }
+ AppEvent::Exit(mode) => {
+ return Ok(self.handle_exit_mode(app_server, mode).await);
+ }
+ AppEvent::FatalExitRequest(message) => {
+ return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
+ }
+ AppEvent::CodexOp(op) => {
+ self.submit_active_thread_op(tui, app_server, op.into())
+ .await?;
+ }
+ AppEvent::SubmitThreadOp { thread_id, op } => {
+ self.submit_thread_op(tui, app_server, thread_id, op.into())
+ .await?;
+ }
+ AppEvent::SubmitWorkflowFollowup { thread_id, op } => {
+ self.submit_thread_op(tui, app_server, thread_id, op.into())
+ .await?;
+ }
+ AppEvent::ThreadHistoryEntryResponse { thread_id, event } => {
+ self.enqueue_thread_history_entry_response(thread_id, event)
+ .await?;
+ }
+ AppEvent::DiffResult(text) => {
+ // Clear the in-progress state in the bottom pane
+ self.chat_widget.on_diff_complete();
+ // Enter alternate screen using TUI helper and build pager lines
+ let _ = tui.enter_alt_screen();
+ let pager_lines: Vec> = if text.trim().is_empty() {
+ vec!["No changes detected.".italic().into()]
+ } else {
+ text.lines().map(ansi_escape_line).collect()
+ };
+ self.overlay = Some(Overlay::new_static_with_lines(
+ pager_lines,
+ "D I F F".to_string(),
+ ));
+ tui.frame_requester().schedule_frame();
+ }
+ AppEvent::OpenAppLink {
app_id,
title,
description,
@@ -5136,6 +5897,27 @@ impl App {
AppEvent::UpdateFeatureFlags { updates } => {
self.update_feature_flags(updates).await;
}
+ AppEvent::ToggleDisplayPreference(key) => {
+ let enabled = !self.display_preferences.is_enabled(key);
+ if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
+ .with_profile(self.active_profile.as_deref())
+ .with_edits([display_preference_edit(key, enabled)])
+ .apply()
+ .await
+ {
+ tracing::error!(
+ error = %err,
+ ?key,
+ "failed to persist display preference update"
+ );
+ self.chat_widget
+ .add_error_message(format!("Failed to save UI preference: {err}"));
+ } else {
+ self.display_preferences.set_enabled(key, enabled);
+ set_display_preference_in_config(&mut self.config, key, enabled);
+ self.open_display_preferences_panel();
+ }
+ }
AppEvent::SkipNextWorldWritableScan => {
self.windows_sandbox.skip_world_writable_scan_once = true;
}
@@ -5546,6 +6328,10 @@ impl App {
self.pending_shutdown_exit_thread_id = None;
AppRunControl::Exit(ExitReason::UserRequested)
}
+ ExitMode::RespawnImmediate => {
+ self.pending_shutdown_exit_thread_id = None;
+ AppRunControl::Exit(ExitReason::RespawnRequested)
+ }
}
}
@@ -5696,7 +6482,27 @@ impl App {
.await;
}
+ let after_turn = self
+ .active_thread_id
+ .and_then(|active_thread_id| match &event {
+ ThreadBufferedEvent::Notification(notification) => {
+ workflow_after_turn_last_agent_message(
+ self.primary_thread_id,
+ active_thread_id,
+ notification,
+ )
+ }
+ _ => None,
+ });
self.handle_thread_event_now(event);
+ if let Some(last_agent_message) = after_turn {
+ for cell in self
+ .handle_primary_thread_turn_complete_for_workflows(app_server, last_agent_message)
+ .await
+ {
+ self.insert_visible_history_cell(tui, cell);
+ }
+ }
if self.backtrack_render_pending {
tui.frame_requester().schedule_frame();
}
@@ -5769,12 +6575,12 @@ impl App {
}
async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
- let editor_cmd = match external_editor::resolve_editor_command() {
- Ok(cmd) => cmd,
+ let editor_cmds = match external_editor::resolve_editor_commands() {
+ Ok(cmds) => cmds,
Err(external_editor::EditorError::MissingEditor) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(
- "Cannot open external editor: set $VISUAL or $EDITOR before starting Codex."
+ "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`."
.to_string(),
));
self.reset_external_editor_state(tui);
@@ -5793,7 +6599,7 @@ impl App {
let seed = self.chat_widget.composer_text_with_pending();
let editor_result = tui
.with_restored(tui::RestoreMode::KeepRaw, || async {
- external_editor::run_editor(&seed, &editor_cmd).await
+ external_editor::run_editor(&seed, &editor_cmds).await
})
.await;
self.reset_external_editor_state(tui);
@@ -5831,12 +6637,57 @@ impl App {
tui.frame_requester().schedule_frame();
}
+ fn handle_key_chord_key_event(&mut self, key_event: KeyEvent) -> Option {
+ if self.overlay.is_some()
+ || !self.chat_widget.no_modal_or_popup_active()
+ || self.chat_widget.external_editor_state() != ExternalEditorState::Closed
+ {
+ self.key_chord.clear();
+ return Some(key_event);
+ }
+
+ match self.key_chord.handle_key_event(key_event) {
+ KeyChordResolution::NoMatch => Some(key_event),
+ KeyChordResolution::AwaitingSecondKey | KeyChordResolution::Cancelled => None,
+ KeyChordResolution::Forward(forwarded_key_event) => Some(forwarded_key_event),
+ KeyChordResolution::Matched(action) => {
+ match action {
+ KeyChordAction::UndoLastUserMessage => {
+ self.undo_last_user_message();
+ }
+ KeyChordAction::CopyLatestOutput => {
+ self.chat_widget.copy_latest_output_to_clipboard();
+ }
+ KeyChordAction::RespawnCurrentSession => {
+ if self.chat_widget.can_run_respawn_now() {
+ self.app_event_tx
+ .send(AppEvent::Exit(ExitMode::RespawnImmediate));
+ }
+ }
+ }
+ None
+ }
+ }
+ }
+
async fn handle_key_event(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
key_event: KeyEvent,
) {
+ let mut key_event = key_event;
+ if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
+ && key_event.code != KeyCode::Esc
+ && self.backtrack.primed
+ {
+ self.reset_backtrack_state();
+ }
+ let Some(forwarded_key_event) = self.handle_key_chord_key_event(key_event) else {
+ return;
+ };
+ key_event = forwarded_key_event;
+
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
// agent-switch shortcuts when the composer is empty so we never steal the expected
@@ -5929,14 +6780,10 @@ impl App {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
- } => {
- if self.chat_widget.is_normal_backtrack_mode()
- && self.chat_widget.composer_is_empty()
- {
- self.handle_backtrack_esc_key(tui);
- } else {
- self.chat_widget.handle_key_event(key_event);
- }
+ } if self.chat_widget.is_normal_backtrack_mode()
+ && self.chat_widget.composer_is_empty() =>
+ {
+ self.handle_backtrack_esc_key(tui);
}
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
KeyEvent {
@@ -5955,12 +6802,6 @@ impl App {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
- // Any non-Esc key press should cancel a primed backtrack.
- // This avoids stale "Esc-primed" state after the user starts typing
- // (even if they later backspace to empty).
- if key_event.code != KeyCode::Esc && self.backtrack.primed {
- self.reset_backtrack_state();
- }
self.chat_widget.handle_key_event(key_event);
}
_ => {
@@ -6199,6 +7040,9 @@ impl Drop for App {
#[cfg(test)]
mod tests {
use super::*;
+ mod btw_tests;
+ mod clawbot_tests;
+
use crate::app_backtrack::BacktrackSelection;
use crate::app_backtrack::BacktrackState;
use crate::app_backtrack::user_count;
@@ -6206,6 +7050,7 @@ mod tests {
use crate::chatwidget::ChatWidgetInit;
use crate::chatwidget::create_initial_user_message;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
+ use crate::chatwidget::tests::render_bottom_popup;
use crate::chatwidget::tests::set_chatgpt_auth;
use crate::file_search::FileSearchManager;
use crate::history_cell::AgentMessageCell;
@@ -6214,6 +7059,7 @@ mod tests {
use crate::history_cell::new_session_info;
use crate::multi_agents::AgentPickerThreadEntry;
use assert_matches::assert_matches;
+ use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
@@ -6231,6 +7077,7 @@ mod tests {
use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary;
use codex_app_server_protocol::HookScope as AppServerHookScope;
use codex_app_server_protocol::HookStartedNotification;
+ use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol;
@@ -6248,6 +7095,7 @@ mod tests {
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::TokenUsageBreakdown;
+ use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnError as AppServerTurnError;
@@ -6288,6 +7136,7 @@ mod tests {
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::prelude::Line;
+ use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -6542,6 +7391,7 @@ mod tests {
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
config,
+ display_preferences: app.display_preferences.clone(),
frame_requester: crate::tui::FrameRequester::test_dummy(),
app_event_tx: app.app_event_tx.clone(),
initial_user_message: create_initial_user_message(
@@ -7525,6 +8375,22 @@ mod tests {
));
}
+ #[test]
+ fn closed_state_for_thread_read_status_marks_not_loaded_picker_threads_closed() {
+ assert!(App::closed_state_for_thread_read_status(
+ &codex_app_server_protocol::ThreadStatus::NotLoaded,
+ ThreadLivenessRefreshMode::Picker,
+ ));
+ }
+
+ #[test]
+ fn closed_state_for_thread_read_status_keeps_not_loaded_selection_threads_open() {
+ assert!(!App::closed_state_for_thread_read_status(
+ &codex_app_server_protocol::ThreadStatus::NotLoaded,
+ ThreadLivenessRefreshMode::Selection,
+ ));
+ }
+
#[test]
fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() {
let unmaterialized = color_eyre::eyre::eyre!(
@@ -7672,7 +8538,11 @@ mod tests {
);
let is_available = app
- .refresh_agent_picker_thread_liveness(&mut app_server, thread_id)
+ .refresh_agent_picker_thread_liveness(
+ &mut app_server,
+ thread_id,
+ ThreadLivenessRefreshMode::Picker,
+ )
.await;
assert!(!is_available);
@@ -7681,6 +8551,34 @@ mod tests {
Ok(())
}
+ #[tokio::test]
+ async fn refresh_agent_picker_thread_liveness_keeps_unloaded_selection_targets_attachable()
+ -> Result<()> {
+ let mut app = make_test_app().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await?;
+ let thread_id = started.session.thread_id;
+ app_server.thread_unsubscribe(thread_id).await?;
+
+ let is_available = app
+ .refresh_agent_picker_thread_liveness(
+ &mut app_server,
+ thread_id,
+ ThreadLivenessRefreshMode::Selection,
+ )
+ .await;
+
+ assert!(is_available);
+ assert!(app.agent_navigation.get(&thread_id).is_none());
+ assert!(app.should_attach_live_thread_for_selection(thread_id));
+ Ok(())
+ }
+
#[tokio::test]
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -9054,9 +9952,10 @@ guardian_approval = true
assert_snapshot!("clear_ui_header_fast_status_gpt54_only", rendered);
}
- async fn make_test_app() -> App {
+ pub(super) async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
+ let display_preferences = DisplayPreferences::from_config(&config);
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
@@ -9067,6 +9966,7 @@ guardian_approval = true
app_event_tx,
chat_widget,
config,
+ display_preferences,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
@@ -9082,6 +9982,7 @@ guardian_approval = true
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
+ key_chord: KeyChordState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
@@ -9100,6 +10001,21 @@ guardian_approval = true
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
+ workflow_thread_notification_channels: Arc::new(
+ tokio::sync::Mutex::new(HashMap::new()),
+ ),
+ workflow_file_watch: None,
+ workflow_scheduler: WorkflowSchedulerState::default(),
+ workflow_history: WorkflowHistoryState::default(),
+ btw_session: None,
+ btw_closing_thread_ids: HashSet::new(),
+ clawbot_workspace_root: None,
+ clawbot_provider_task: None,
+ clawbot_pending_turns: HashMap::new(),
+ #[cfg(test)]
+ clawbot_outbound_messages: Vec::new(),
+ #[cfg(test)]
+ clawbot_outbound_reactions: Vec::new(),
}
}
@@ -9110,6 +10026,7 @@ guardian_approval = true
) {
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
+ let display_preferences = DisplayPreferences::from_config(&config);
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
@@ -9121,6 +10038,7 @@ guardian_approval = true
app_event_tx,
chat_widget,
config,
+ display_preferences,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
@@ -9136,6 +10054,7 @@ guardian_approval = true
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
+ key_chord: KeyChordState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
@@ -9154,12 +10073,129 @@ guardian_approval = true
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
+ workflow_thread_notification_channels: Arc::new(tokio::sync::Mutex::new(
+ HashMap::new(),
+ )),
+ workflow_file_watch: None,
+ workflow_scheduler: WorkflowSchedulerState::default(),
+ workflow_history: WorkflowHistoryState::default(),
+ btw_session: None,
+ btw_closing_thread_ids: HashSet::new(),
+ clawbot_workspace_root: None,
+ clawbot_provider_task: None,
+ clawbot_pending_turns: HashMap::new(),
+ #[cfg(test)]
+ clawbot_outbound_messages: Vec::new(),
+ #[cfg(test)]
+ clawbot_outbound_reactions: Vec::new(),
},
rx,
op_rx,
)
}
+ fn write_test_before_turn_workflow(workspace_cwd: &Path) -> Result<()> {
+ let workflows_dir = workspace_cwd.join(".codex/workflows");
+ std::fs::create_dir_all(&workflows_dir)?;
+ std::fs::write(
+ workflows_dir.join("before_turn.yaml"),
+ r#"name: director
+
+triggers:
+ - type: before_turn
+ jobs: [augment]
+
+jobs:
+ augment:
+ context: embed
+ steps:
+ - prompt: |
+ added by before_turn
+"#,
+ )?;
+ Ok(())
+ }
+
+ fn write_test_after_turn_workflow(workspace_cwd: &Path) -> Result<()> {
+ let workflows_dir = workspace_cwd.join(".codex/workflows");
+ std::fs::create_dir_all(&workflows_dir)?;
+ std::fs::write(
+ workflows_dir.join("after_turn.yaml"),
+ r#"name: director
+
+triggers:
+ - type: after_turn
+ id: followup
+ jobs: [followup]
+
+jobs:
+ followup:
+ context: embed
+ steps:
+ - prompt: |
+ follow up from workflow
+"#,
+ )?;
+ Ok(())
+ }
+
+ fn write_test_manual_workflow(workspace_cwd: &Path) -> Result<()> {
+ let workflows_dir = workspace_cwd.join(".codex/workflows");
+ std::fs::create_dir_all(&workflows_dir)?;
+ std::fs::write(
+ workflows_dir.join("manual.yaml"),
+ r#"name: director
+
+triggers:
+ - type: manual
+ id: review_backlog
+ jobs: [summarize]
+ - type: manual
+ id: triage
+ jobs: [notify]
+ - type: after_turn
+ id: followup
+ jobs: [notify]
+
+jobs:
+ summarize:
+ context: embed
+ steps:
+ - prompt: |
+ summarize the backlog
+ notify:
+ context: embed
+ response: user
+ steps:
+ - prompt: |
+ send workflow update
+"#,
+ )?;
+ Ok(())
+ }
+
+ fn write_test_file_watch_workflow(workspace_cwd: &Path) -> Result<()> {
+ let workflows_dir = workspace_cwd.join(".codex/workflows");
+ std::fs::create_dir_all(&workflows_dir)?;
+ std::fs::write(
+ workflows_dir.join("file_watch.yaml"),
+ r#"name: watcher
+
+triggers:
+ - type: file_watch
+ id: refresh
+ jobs: [summarize]
+
+jobs:
+ summarize:
+ context: embed
+ steps:
+ - prompt: |
+ summarize the latest file changes
+"#,
+ )?;
+ Ok(())
+ }
fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState {
ThreadSessionState {
thread_id,
@@ -9207,6 +10243,27 @@ guardian_approval = true
})
}
+ fn turn_completed_notification_with_agent_message(
+ thread_id: ThreadId,
+ turn_id: &str,
+ status: TurnStatus,
+ message: &str,
+ ) -> ServerNotification {
+ ServerNotification::TurnCompleted(TurnCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn: test_turn(
+ turn_id,
+ status,
+ vec![ThreadItem::AgentMessage {
+ id: "agent-1".to_string(),
+ text: message.to_string(),
+ phase: None,
+ memory_citation: None,
+ }],
+ ),
+ })
+ }
+
fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification {
ServerNotification::ThreadClosed(ThreadClosedNotification {
thread_id: thread_id.to_string(),
@@ -9334,6 +10391,22 @@ guardian_approval = true
}
}
+ fn request_user_input_request(
+ thread_id: ThreadId,
+ turn_id: &str,
+ item_id: &str,
+ ) -> ServerRequest {
+ ServerRequest::ToolRequestUserInput {
+ request_id: AppServerRequestId::Integer(99),
+ params: ToolRequestUserInputParams {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.to_string(),
+ item_id: item_id.to_string(),
+ questions: Vec::new(),
+ },
+ }
+ }
+
#[test]
fn thread_event_store_tracks_active_turn_lifecycle() {
let mut store = ThreadEventStore::new(/*capacity*/ 8);
@@ -10101,14 +11174,71 @@ guardian_approval = true
}
#[tokio::test]
- async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error()
- -> Result<()> {
+ async fn refresh_in_memory_config_from_disk_syncs_active_profile() -> Result<()> {
let mut app = make_test_app().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf();
- std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
- let current_config = app.config.clone();
- let current_cwd = current_config.cwd.clone();
+ app.active_profile = Some("stale".to_string());
+
+ std::fs::write(
+ codex_home.path().join("config.toml"),
+ r#"
+profile = "fresh"
+
+[profiles.fresh]
+model = "gpt-5.2"
+"#,
+ )?;
+
+ app.refresh_in_memory_config_from_disk().await?;
+
+ assert_eq!(app.config.active_profile.as_deref(), Some("fresh"));
+ assert_eq!(app.active_profile.as_deref(), Some("fresh"));
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn routed_profile_runtime_changed_detects_profile_and_provider_reload_inputs() {
+ let app = make_test_app().await;
+ let current_config = app.config.clone();
+
+ let mut next_profile = current_config.clone();
+ next_profile.active_profile = Some("secondary".to_string());
+ assert!(App::routed_profile_runtime_changed(
+ ¤t_config,
+ &next_profile
+ ));
+
+ let mut next_provider = current_config.clone();
+ next_provider.model_provider.base_url = Some("https://example.com/v1".to_string());
+ assert!(App::routed_profile_runtime_changed(
+ ¤t_config,
+ &next_provider
+ ));
+
+ let mut next_chatgpt_base_url = current_config.clone();
+ next_chatgpt_base_url.chatgpt_base_url =
+ "https://chatgpt.example.com/backend-api/".to_string();
+ assert!(App::routed_profile_runtime_changed(
+ ¤t_config,
+ &next_chatgpt_base_url
+ ));
+
+ assert!(!App::routed_profile_runtime_changed(
+ ¤t_config,
+ ¤t_config
+ ));
+ }
+
+ #[tokio::test]
+ async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error()
+ -> Result<()> {
+ let mut app = make_test_app().await;
+ let codex_home = tempdir()?;
+ app.config.codex_home = codex_home.path().to_path_buf();
+ std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
+ let current_config = app.config.clone();
+ let current_cwd = current_config.cwd.clone();
let resume_config = app
.rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf())
@@ -10402,6 +11532,155 @@ guardian_approval = true
}));
}
+ #[tokio::test]
+ async fn undo_last_user_message_restores_latest_user_input_and_rolls_back_one_turn() {
+ let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
+
+ let remote_image_url = "https://example.com/latest.png".to_string();
+ app.transcript_cells = vec![
+ Arc::new(UserHistoryCell {
+ message: "first".to_string(),
+ text_elements: Vec::new(),
+ local_image_paths: Vec::new(),
+ remote_image_urls: Vec::new(),
+ }) as Arc,
+ Arc::new(UserHistoryCell {
+ message: "latest".to_string(),
+ text_elements: vec![TextElement::new(
+ codex_protocol::user_input::ByteRange { start: 0, end: 6 },
+ Some("latest".to_string()),
+ )],
+ local_image_paths: Vec::new(),
+ remote_image_urls: vec![remote_image_url.clone()],
+ }) as Arc,
+ ];
+ app.chat_widget
+ .set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());
+
+ assert!(app.undo_last_user_message());
+ assert_eq!(app.chat_widget.composer_text_with_pending(), "latest");
+ assert_eq!(app.chat_widget.remote_image_urls(), vec![remote_image_url]);
+
+ let mut rollback_turns = None;
+ while let Ok(op) = op_rx.try_recv() {
+ if let Op::ThreadRollback { num_turns } = op {
+ rollback_turns = Some(num_turns);
+ }
+ }
+
+ assert_eq!(rollback_turns, Some(1));
+ }
+
+ #[tokio::test]
+ async fn ctrl_x_ctrl_u_triggers_undo_last_user_message() {
+ let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
+
+ let thread_id = ThreadId::new();
+ app.chat_widget.handle_codex_event(Event {
+ id: String::new(),
+ msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
+ session_id: thread_id,
+ forked_from_id: None,
+ thread_name: None,
+ model: "gpt-test".to_string(),
+ model_provider_id: "test-provider".to_string(),
+ service_tier: None,
+ approval_policy: AskForApproval::Never,
+ approvals_reviewer: ApprovalsReviewer::User,
+ sandbox_policy: SandboxPolicy::new_read_only_policy(),
+ cwd: PathBuf::from("/home/user/project"),
+ reasoning_effort: None,
+ history_log_id: 0,
+ history_entry_count: 0,
+ initial_messages: None,
+ network_proxy: None,
+ rollout_path: Some(PathBuf::new()),
+ }),
+ });
+
+ app.transcript_cells = vec![
+ Arc::new(UserHistoryCell {
+ message: "first".to_string(),
+ text_elements: Vec::new(),
+ local_image_paths: Vec::new(),
+ remote_image_urls: Vec::new(),
+ }) as Arc,
+ Arc::new(UserHistoryCell {
+ message: "latest".to_string(),
+ text_elements: vec![TextElement::new(
+ codex_protocol::user_input::ByteRange { start: 0, end: 6 },
+ Some("latest".to_string()),
+ )],
+ local_image_paths: Vec::new(),
+ remote_image_urls: Vec::new(),
+ }) as Arc,
+ ];
+
+ assert_eq!(
+ app.handle_key_chord_key_event(KeyEvent::new(
+ KeyCode::Char('x'),
+ KeyModifiers::CONTROL,
+ )),
+ None
+ );
+ assert_eq!(
+ app.handle_key_chord_key_event(KeyEvent::new(
+ KeyCode::Char('u'),
+ KeyModifiers::CONTROL,
+ )),
+ None
+ );
+
+ let mut rollback_turns = None;
+ while let Ok(op) = op_rx.try_recv() {
+ if let Op::ThreadRollback { num_turns } = op {
+ rollback_turns = Some(num_turns);
+ }
+ }
+
+ assert_eq!(rollback_turns, Some(1));
+ }
+
+ #[tokio::test]
+ async fn ctrl_x_unknown_second_key_falls_through_to_composer_input() {
+ let mut app = make_test_app().await;
+
+ assert_eq!(
+ app.handle_key_chord_key_event(KeyEvent::new(
+ KeyCode::Char('x'),
+ KeyModifiers::CONTROL,
+ )),
+ None
+ );
+ let forwarded =
+ app.handle_key_chord_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
+ assert_eq!(
+ forwarded,
+ Some(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))
+ );
+ }
+
+ #[tokio::test]
+ async fn ctrl_x_ctrl_y_runs_copy_action_without_inserting_y_into_the_composer() {
+ let mut app = make_test_app().await;
+
+ assert_eq!(
+ app.handle_key_chord_key_event(KeyEvent::new(
+ KeyCode::Char('x'),
+ KeyModifiers::CONTROL,
+ )),
+ None
+ );
+ assert_eq!(
+ app.handle_key_chord_key_event(KeyEvent::new(
+ KeyCode::Char('y'),
+ KeyModifiers::CONTROL,
+ )),
+ None
+ );
+ assert_eq!(app.chat_widget.composer_text_with_pending(), "");
+ }
+
#[tokio::test]
async fn replay_thread_snapshot_replays_turn_history_in_order() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -10474,6 +11753,130 @@ guardian_approval = true
);
}
+ #[tokio::test]
+ async fn replay_thread_snapshot_queues_workflow_history_after_turn_replay() {
+ let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
+ let thread_id = ThreadId::new();
+ app.active_thread_id = Some(thread_id);
+ let stored_cell: Arc = Arc::new(history_cell::new_info_event(
+ "Workflow reply".to_string(),
+ Some("director/review_backlog".to_string()),
+ ));
+ let _ = app.record_workflow_history_cell(thread_id, stored_cell);
+
+ app.replay_thread_snapshot(
+ ThreadEventSnapshot {
+ session: Some(test_thread_session(
+ thread_id,
+ PathBuf::from("/home/user/project"),
+ )),
+ turns: vec![test_turn(
+ "turn-1",
+ TurnStatus::Completed,
+ vec![ThreadItem::UserMessage {
+ id: "user-1".to_string(),
+ content: vec![AppServerUserInput::Text {
+ text: "first prompt".to_string(),
+ text_elements: Vec::new(),
+ }],
+ }],
+ )],
+ events: Vec::new(),
+ input_state: None,
+ },
+ /*resume_restored_queue*/ false,
+ );
+
+ let mut replay_order = Vec::new();
+ while let Ok(event) = app_event_rx.try_recv() {
+ match event {
+ AppEvent::InsertHistoryCell(cell) => {
+ let cell: Arc = cell.into();
+ let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
+ if transcript.contains("first prompt") {
+ replay_order.push("turn");
+ }
+ app.transcript_cells.push(cell);
+ }
+ AppEvent::ReplayWorkflowHistory {
+ thread_id: replay_thread_id,
+ } => {
+ assert_eq!(replay_thread_id, thread_id);
+ replay_order.push("workflow");
+ let lines = app.replay_workflow_history_cells_for_thread(
+ replay_thread_id,
+ /*width*/ 80,
+ );
+ assert!(lines_to_single_string(&lines).contains("Workflow reply"));
+ }
+ _ => {}
+ }
+ }
+
+ assert_eq!(replay_order, vec!["turn", "workflow"]);
+ let transcript: Vec = app
+ .transcript_cells
+ .iter()
+ .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
+ .collect();
+ assert!(transcript.iter().any(|cell| cell.contains("first prompt")));
+ assert!(
+ transcript
+ .last()
+ .is_some_and(|cell| cell.contains("Workflow reply"))
+ );
+ }
+
+ #[tokio::test]
+ async fn enqueue_primary_thread_session_queues_workflow_history_after_turn_replay() -> Result<()>
+ {
+ let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
+ let thread_id = ThreadId::new();
+ let stored_cell: Arc = Arc::new(history_cell::new_info_event(
+ "Workflow reply".to_string(),
+ Some("director/review_backlog".to_string()),
+ ));
+ let _ = app.record_workflow_history_cell(thread_id, stored_cell);
+
+ app.enqueue_primary_thread_session(
+ test_thread_session(thread_id, PathBuf::from("/tmp/project")),
+ vec![test_turn(
+ "turn-1",
+ TurnStatus::Completed,
+ vec![ThreadItem::UserMessage {
+ id: "user-1".to_string(),
+ content: vec![AppServerUserInput::Text {
+ text: "earlier prompt".to_string(),
+ text_elements: Vec::new(),
+ }],
+ }],
+ )],
+ )
+ .await?;
+
+ let mut replay_order = Vec::new();
+ while let Ok(event) = app_event_rx.try_recv() {
+ match event {
+ AppEvent::InsertHistoryCell(cell) => {
+ let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
+ if transcript.contains("earlier prompt") {
+ replay_order.push("turn");
+ }
+ }
+ AppEvent::ReplayWorkflowHistory {
+ thread_id: replay_thread_id,
+ } => {
+ assert_eq!(replay_thread_id, thread_id);
+ replay_order.push("workflow");
+ }
+ _ => {}
+ }
+ }
+
+ assert_eq!(replay_order, vec!["turn", "workflow"]);
+ Ok(())
+ }
+
#[tokio::test]
async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -10488,6 +11891,7 @@ guardian_approval = true
let replacement = ChatWidget::new_with_app_event(ChatWidgetInit {
config: app.config.clone(),
+ display_preferences: app.display_preferences.clone(),
frame_requester: crate::tui::FrameRequester::test_dummy(),
app_event_tx: app.app_event_tx.clone(),
initial_user_message: None,
@@ -10757,29 +12161,728 @@ guardian_approval = true
}
#[tokio::test]
- async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
+ async fn shutting_down_primary_thread_stops_background_workflow_runs() {
let mut app = make_test_app().await;
- let thread_id = ThreadId::new();
- app.active_thread_id = Some(thread_id);
-
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
- let control = app
- .handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)
- .await;
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await
+ .expect("start thread");
+ let thread_id = started.session.thread_id;
+ app.primary_thread_id = Some(thread_id);
+ app.chat_widget.handle_codex_event(Event {
+ id: String::new(),
+ msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
+ session_id: thread_id,
+ forked_from_id: started.session.forked_from_id,
+ thread_name: started.session.thread_name.clone(),
+ model: started.session.model.clone(),
+ model_provider_id: started.session.model_provider_id.clone(),
+ service_tier: started.session.service_tier,
+ approval_policy: started.session.approval_policy,
+ approvals_reviewer: started.session.approvals_reviewer,
+ sandbox_policy: started.session.sandbox_policy.clone(),
+ cwd: started.session.cwd.clone(),
+ reasoning_effort: started.session.reasoning_effort,
+ history_log_id: started.session.history_log_id,
+ history_entry_count: usize::try_from(started.session.history_entry_count)
+ .expect("history entry count fits usize"),
+ initial_messages: None,
+ network_proxy: started.session.network_proxy.clone(),
+ rollout_path: started.session.rollout_path.clone(),
+ }),
+ });
- assert_eq!(app.pending_shutdown_exit_thread_id, None);
- assert!(matches!(
- control,
- AppRunControl::Exit(ExitReason::UserRequested)
- ));
+ let _run_id = app.start_test_background_workflow_run(
+ "director".to_string(),
+ "review_backlog".to_string(),
+ /*is_trigger*/ false,
+ );
+ assert_eq!(
+ app.background_workflow_labels(),
+ vec!["director · review_backlog".to_string()]
+ );
+
+ app.shutdown_current_thread(&mut app_server).await;
+
+ assert!(app.background_workflow_labels().is_empty());
+ assert!(app.queued_trigger_labels().is_empty());
}
#[tokio::test]
- async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() {
- let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
+ async fn before_turn_workflow_augments_primary_user_turn() -> Result<()> {
+ let mut app = make_test_app().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let tempdir = tempdir()?;
+ app.config.cwd = tempdir.path().to_path_buf().abs();
+ write_test_before_turn_workflow(app.config.cwd.as_path())?;
+
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await
+ .expect("start thread");
+ let thread_id = started.session.thread_id;
+ app.primary_thread_id = Some(thread_id);
+
+ let (op, cells) = app
+ .augment_primary_thread_op_with_before_turn_workflows(
+ &app_server,
+ thread_id,
+ AppCommand::from_core(Op::UserTurn {
+ items: vec![UserInput::Text {
+ text: "original prompt".to_string(),
+ text_elements: Vec::new(),
+ }],
+ cwd: app.config.cwd.to_path_buf(),
+ approval_policy: AskForApproval::Never,
+ approvals_reviewer: Some(ApprovalsReviewer::User),
+ sandbox_policy: SandboxPolicy::new_read_only_policy(),
+ model: "gpt-test".to_string(),
+ effort: None,
+ summary: None,
+ service_tier: None,
+ final_output_json_schema: None,
+ collaboration_mode: None,
+ personality: None,
+ }),
+ )
+ .await;
+
+ let AppCommandView::UserTurn { items, .. } = op.view() else {
+ panic!("expected user turn");
+ };
+ assert_eq!(
+ items,
+ &[
+ UserInput::Text {
+ text: "original prompt".to_string(),
+ text_elements: Vec::new(),
+ },
+ UserInput::Text {
+ text: "added by before_turn".to_string(),
+ text_elements: Vec::new(),
+ }
+ ]
+ );
+ let rendered_cells: Vec = cells
+ .iter()
+ .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
+ .collect();
+ assert_eq!(rendered_cells.len(), 1);
+ assert!(rendered_cells[0].contains("Workflow job completed"));
+ Ok(())
+ }
+ #[tokio::test]
+ async fn active_primary_turn_complete_waits_for_consumption_before_after_turn() -> Result<()> {
+ let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let tempdir = tempdir()?;
+ app.config.cwd = tempdir.path().to_path_buf().abs();
+ write_test_after_turn_workflow(app.config.cwd.as_path())?;
+
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await
+ .expect("start thread");
+ let thread_id = started.session.thread_id;
+ app.enqueue_primary_thread_session(started.session, Vec::new())
+ .await?;
+ while app_event_rx.try_recv().is_ok() {}
+
+ app.enqueue_thread_notification(
+ thread_id,
+ turn_completed_notification_with_agent_message(
+ thread_id,
+ "turn-1",
+ TurnStatus::Completed,
+ "final reply",
+ ),
+ )
+ .await?;
+
+ assert!(
+ app_event_rx.try_recv().is_err(),
+ "after_turn should not run before the active thread consumes TurnCompleted"
+ );
+
+ let queued_event = app
+ .active_thread_rx
+ .as_mut()
+ .expect("active thread receiver")
+ .recv()
+ .await
+ .expect("queued active-thread event");
+ app.handle_thread_event_now(queued_event);
+
+ while let Ok(event) = app_event_rx.try_recv() {
+ match event {
+ AppEvent::ClawbotTurnCompleted { .. }
+ | AppEvent::InsertHistoryCell(_)
+ | AppEvent::ReplayWorkflowHistory { .. } => {
+ continue;
+ }
+ other => {
+ panic!(
+ "after_turn should not run before event consumption finishes, got {other:?}"
+ );
+ }
+ }
+ }
+
+ let visible_cells = app
+ .handle_primary_thread_turn_complete_for_workflows(
+ &app_server,
+ Some("final reply".to_string()),
+ )
+ .await;
+ assert_eq!(visible_cells.len(), 1);
+ let rendered_cells: Vec = visible_cells
+ .iter()
+ .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
+ .collect();
+ assert!(rendered_cells[0].contains("Workflow trigger started"));
+ assert_eq!(
+ app.background_workflow_labels(),
+ vec!["director · followup".to_string()]
+ );
+
+ let (run_id, result) = loop {
+ match tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
+ .await?
+ .expect("expected background workflow event")
+ {
+ AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => {
+ break (run_id, result);
+ }
+ other => panic!("expected background workflow completion event, got {other:?}"),
+ }
+ };
+ let completion_cells = app
+ .finish_background_workflow_run(&app_server, run_id, *result)
+ .await;
+ let rendered_completion: Vec = completion_cells
+ .iter()
+ .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
+ .collect();
+ assert_eq!(rendered_completion.len(), 2);
+ assert!(rendered_completion[0].contains("Workflow trigger completed"));
+ assert!(rendered_completion[1].contains("Workflow reply"));
+
+ match app_event_rx
+ .try_recv()
+ .expect("expected workflow follow-up submission")
+ {
+ AppEvent::SubmitWorkflowFollowup {
+ thread_id: submit_thread_id,
+ op: Op::UserTurn { items, .. },
+ } => {
+ assert_eq!(submit_thread_id, thread_id);
+ assert_eq!(
+ items,
+ vec![UserInput::Text {
+ text: "follow up from workflow".to_string(),
+ text_elements: Vec::new(),
+ }]
+ );
+ }
+ other => panic!("expected workflow follow-up submission, got {other:?}"),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn inactive_primary_turn_complete_still_runs_after_turn_continuity() -> Result<()> {
+ let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let tempdir = tempdir()?;
+ app.config.cwd = tempdir.path().to_path_buf().abs();
+ write_test_after_turn_workflow(app.config.cwd.as_path())?;
+
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await
+ .expect("start thread");
+ let thread_id = started.session.thread_id;
+ app.enqueue_primary_thread_session(started.session, Vec::new())
+ .await?;
+ let agent_thread_id = ThreadId::new();
+ app.active_thread_id = Some(agent_thread_id);
+ while app_event_rx.try_recv().is_ok() {}
+
+ app.handle_app_server_event(
+ &app_server,
+ AppServerEvent::ServerNotification(turn_completed_notification_with_agent_message(
+ thread_id,
+ "turn-1",
+ TurnStatus::Completed,
+ "final reply",
+ )),
+ )
+ .await;
+
+ let (run_id, result) = loop {
+ match tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
+ .await?
+ .expect("expected inactive primary background workflow event")
+ {
+ AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => {
+ break (run_id, result);
+ }
+ AppEvent::ClawbotTurnCompleted { .. }
+ | AppEvent::InsertHistoryCell(_)
+ | AppEvent::ReplayWorkflowHistory { .. } => {
+ continue;
+ }
+ other => panic!("expected background workflow completion event, got {other:?}"),
+ }
+ };
+ let visible_cells = app
+ .finish_background_workflow_run(&app_server, run_id, *result)
+ .await;
+ assert!(
+ visible_cells.is_empty(),
+ "inactive primary thread should record workflow cells without rendering them immediately"
+ );
+
+ match app_event_rx
+ .try_recv()
+ .expect("expected inactive primary workflow follow-up submission")
+ {
+ AppEvent::SubmitWorkflowFollowup {
+ thread_id: submit_thread_id,
+ op: Op::UserTurn { items, .. },
+ } => {
+ assert_eq!(submit_thread_id, thread_id);
+ assert_eq!(
+ items,
+ vec![UserInput::Text {
+ text: "follow up from workflow".to_string(),
+ text_elements: Vec::new(),
+ }]
+ );
+ }
+ other => panic!("expected workflow follow-up submission, got {other:?}"),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn workflow_ui_popup_snapshot() -> Result<()> {
+ let mut app = make_test_app().await;
+ let tempdir = tempdir()?;
+ app.config.cwd = tempdir.path().to_path_buf().abs();
+ write_test_manual_workflow(app.config.cwd.as_path())?;
+
+ let slow_run = app
+ .start_test_manual_workflow_trigger_run(
+ "director".to_string(),
+ "review_backlog".to_string(),
+ )
+ .expect("running manual trigger");
+ let queued_run = app
+ .start_test_manual_workflow_trigger_run("director".to_string(), "triage".to_string());
+ assert!(queued_run.is_none());
+
+ app.open_workflow_controls_popup();
+
+ let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100);
+ assert_snapshot!("workflow_controls_popup", popup);
+
+ app.finish_test_background_workflow_run(slow_run).await;
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn workflow_ui_manual_trigger_action_updates_scheduler_status() -> Result<()> {
+ let mut app = make_test_app().await;
+ let tempdir = tempdir()?;
+ app.config.cwd = tempdir.path().to_path_buf().abs();
+ write_test_manual_workflow(app.config.cwd.as_path())?;
+
+ let started_app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
+ let cell = app.start_manual_workflow_trigger_from_ui(
+ &started_app_server,
+ "director".to_string(),
+ "review_backlog".to_string(),
+ );
+ let rendered = cell
+ .display_lines(/*width*/ 100)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n");
+ assert!(rendered.contains("Workflow trigger started"));
+ assert_eq!(
+ app.background_workflow_labels(),
+ vec!["director · review_backlog".to_string()]
+ );
+
+ let stopped = app.workflow_scheduler.stop_active_workflow_runs().await;
+ assert_eq!(stopped, 1);
+ app.sync_background_workflow_status();
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn clean_background_terminals_stops_background_workflows() -> Result<()> {
+ let mut app = make_test_app().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await?;
+ let thread_id = started.session.thread_id;
+ app.primary_thread_id = Some(thread_id);
+ app.primary_session_configured = Some(started.session);
+ app.active_thread_id = Some(thread_id);
+
+ let run_id = app
+ .workflow_scheduler
+ .next_background_run_id("director", "review_backlog");
+ let cancellation = CancellationToken::new();
+ let cancellation_for_task = cancellation.clone();
+ app.workflow_scheduler.register_background_workflow_run(
+ run_id,
+ workflow_runtime::BackgroundWorkflowRunTarget::Job {
+ workflow_name: "director".to_string(),
+ job_name: "review_backlog".to_string(),
+ },
+ cancellation,
+ tokio::spawn(async move {
+ cancellation_for_task.cancelled().await;
+ }),
+ );
+ app.workflow_scheduler.enqueue_trigger_run(
+ "director".to_string(),
+ "triage".to_string(),
+ workflow_runtime::OwnedWorkflowPhaseContext::default(),
+ );
+ app.sync_background_workflow_status();
+
+ assert_eq!(
+ app.background_workflow_labels(),
+ vec!["director · review_backlog".to_string()]
+ );
+ assert_eq!(
+ app.queued_trigger_labels(),
+ vec!["director · triage".to_string()]
+ );
+
+ let handled = app
+ .try_submit_active_thread_op_via_app_server(
+ &mut app_server,
+ thread_id,
+ &AppCommand::clean_background_terminals(),
+ )
+ .await?;
+
+ assert!(handled);
+ assert!(app.background_workflow_labels().is_empty());
+ assert!(app.queued_trigger_labels().is_empty());
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn manual_triggers_use_a_global_fifo_queue() {
+ let mut app = make_test_app().await;
+
+ let slow_run =
+ app.start_test_manual_workflow_trigger_run("director".to_string(), "slow".to_string());
+ let fast_run =
+ app.start_test_manual_workflow_trigger_run("director".to_string(), "fast".to_string());
+
+ assert!(slow_run.is_some());
+ assert!(fast_run.is_none());
+ assert_eq!(
+ app.background_workflow_labels(),
+ vec!["director · slow".to_string()]
+ );
+ assert_eq!(
+ app.queued_trigger_labels(),
+ vec!["director · fast".to_string()]
+ );
+
+ app.finish_test_background_workflow_run(slow_run.expect("slow run id"))
+ .await;
+
+ assert_eq!(
+ app.background_workflow_labels(),
+ vec!["director · fast".to_string()]
+ );
+ assert!(app.queued_trigger_labels().is_empty());
+ }
+
+ #[tokio::test]
+ async fn file_watch_triggers_share_the_global_queue_and_skip_duplicates() -> Result<()> {
+ let mut app = make_test_app().await;
+ write_test_file_watch_workflow(app.config.cwd.as_path())?;
+
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await?;
+ let thread_id = started.session.thread_id;
+ app.primary_thread_id = Some(thread_id);
+ app.primary_session_configured = Some(started.session);
+ app.active_thread_id = Some(thread_id);
+
+ let running =
+ app.start_test_manual_workflow_trigger_run("director".to_string(), "slow".to_string());
+ assert!(running.is_some());
+
+ let first = app.handle_workspace_file_changes_for_workflows(
+ &app_server,
+ &[app.config.cwd.as_path().join("src")],
+ );
+ let second = app.handle_workspace_file_changes_for_workflows(
+ &app_server,
+ &[app.config.cwd.as_path().join("src/lib.rs")],
+ );
+
+ assert_eq!(first.len(), 1);
+ assert!(second.is_empty());
+ assert_eq!(
+ app.background_workflow_labels(),
+ vec!["director · slow".to_string()]
+ );
+ assert_eq!(
+ app.queued_trigger_labels(),
+ vec!["watcher · refresh".to_string()]
+ );
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn workflow_follow_up_completion_submits_to_primary_thread() {
+ let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await
+ .expect("start thread");
+ let thread_id = started.session.thread_id;
+ app.primary_thread_id = Some(thread_id);
+ app.primary_session_configured = Some(started.session);
+ app.active_thread_id = Some(thread_id);
+
+ let run_id = app
+ .workflow_scheduler
+ .next_background_run_id("director", "review_backlog");
+ let target = workflow_runtime::BackgroundWorkflowRunTarget::Job {
+ workflow_name: "director".to_string(),
+ job_name: "review_backlog".to_string(),
+ };
+ app.workflow_scheduler.register_background_workflow_run(
+ run_id.clone(),
+ target.clone(),
+ CancellationToken::new(),
+ tokio::spawn(async {}),
+ );
+
+ let cells = app
+ .finish_background_workflow_run(
+ &app_server,
+ run_id,
+ workflow_runtime::BackgroundWorkflowRunResult {
+ target,
+ outcome: workflow_runtime::BackgroundWorkflowRunOutcome::Completed(vec![
+ workflow_runtime::WorkflowJobRunResult {
+ delivery: workflow_runtime::WorkflowOutputDelivery::UserFollowup,
+ workflow_name: "director".to_string(),
+ trigger_id: "job:review_backlog".to_string(),
+ job_name: "review_backlog".to_string(),
+ message: Some("workflow follow-up".to_string()),
+ },
+ ]),
+ },
+ )
+ .await;
+
+ let rendered_cells: Vec = cells
+ .iter()
+ .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
+ .collect();
+ assert_eq!(rendered_cells.len(), 2);
+ assert!(rendered_cells[0].contains("Workflow job completed"));
+ assert!(rendered_cells[1].contains("Workflow reply"));
+
+ match app_event_rx
+ .try_recv()
+ .expect("expected queued workflow follow-up")
+ {
+ AppEvent::SubmitWorkflowFollowup {
+ thread_id: submit_thread_id,
+ op: Op::UserTurn { items, .. },
+ } => {
+ assert_eq!(submit_thread_id, thread_id);
+ assert_eq!(
+ items,
+ vec![UserInput::Text {
+ text: "workflow follow-up".to_string(),
+ text_elements: Vec::new(),
+ }]
+ );
+ }
+ other => panic!("expected workflow follow-up submission, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn workflow_followup_completion_retriggers_after_turn() -> Result<()> {
+ let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let tempdir = tempdir()?;
+ app.config.cwd = tempdir.path().to_path_buf().abs();
+ write_test_after_turn_workflow(app.config.cwd.as_path())?;
+
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await
+ .expect("start thread");
+ let thread_id = started.session.thread_id;
+ app.enqueue_primary_thread_session(started.session, Vec::new())
+ .await?;
+ app.active_thread_id = Some(ThreadId::new());
+ while app_event_rx.try_recv().is_ok() {}
+
+ app.handle_app_server_event(
+ &app_server,
+ AppServerEvent::ServerNotification(turn_completed_notification_with_agent_message(
+ thread_id,
+ "turn-followup",
+ TurnStatus::Completed,
+ "workflow-originated follow-up reply",
+ )),
+ )
+ .await;
+
+ assert_eq!(
+ app.background_workflow_labels(),
+ vec!["director · followup".to_string()]
+ );
+ while let Ok(event) = app_event_rx.try_recv() {
+ match event {
+ AppEvent::ClawbotTurnCompleted { .. }
+ | AppEvent::InsertHistoryCell(_)
+ | AppEvent::ReplayWorkflowHistory { .. } => {}
+ other => panic!("unexpected event after workflow follow-up completion: {other:?}"),
+ }
+ }
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn app_server_notifications_forward_to_workflow_thread_receivers() -> Result<()> {
+ let mut app = make_test_app().await;
+ let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let thread_id = ThreadId::new();
+ let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+ app.workflow_thread_notification_channels
+ .lock()
+ .await
+ .insert(thread_id, sender);
+
+ let notification = ServerNotification::ItemCompleted(ItemCompletedNotification {
+ item: ThreadItem::AgentMessage {
+ id: "msg-1".to_string(),
+ text: "workflow reply".to_string(),
+ phase: None,
+ memory_citation: None,
+ },
+ thread_id: thread_id.to_string(),
+ turn_id: "turn-1".to_string(),
+ });
+
+ app.handle_app_server_event(
+ &app_server,
+ AppServerEvent::ServerNotification(notification.clone()),
+ )
+ .await;
+
+ match receiver.recv().await {
+ Some(ServerNotification::ItemCompleted(received)) => {
+ assert_eq!(received.thread_id, thread_id.to_string());
+ assert_eq!(received.turn_id, "turn-1");
+ let ThreadItem::AgentMessage { text, .. } = received.item else {
+ panic!("expected forwarded workflow agent message");
+ };
+ assert_eq!(text, "workflow reply");
+ }
+ other => panic!("expected forwarded workflow notification, got {other:?}"),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn btw_insert_summary_appends_to_existing_composer() -> Result<()> {
+ let mut app = make_test_app().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
+ let thread_id = ThreadId::new();
+ app.btw_session = Some(BtwSessionState {
+ thread_id,
+ final_message: Some("First point.\n\nSecond point.".to_string()),
+ last_status: None,
+ });
+ app.chat_widget
+ .set_composer_text("Existing draft".to_string(), Vec::new(), Vec::new());
+
+ app.insert_btw_summary(&mut app_server).await;
+
+ assert_eq!(
+ app.chat_widget.composer_text_with_pending(),
+ "Existing draft\n\nBTW summary:\nFirst point.\nSecond point."
+ );
+ assert!(app.btw_session.is_none());
+ Ok(())
+ }
+ #[tokio::test]
+ async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
+ let mut app = make_test_app().await;
+ let thread_id = ThreadId::new();
+ app.active_thread_id = Some(thread_id);
+
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let control = app
+ .handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)
+ .await;
+
+ assert_eq!(app.pending_shutdown_exit_thread_id, None);
+ assert!(matches!(
+ control,
+ AppRunControl::Exit(ExitReason::UserRequested)
+ ));
+ }
+
+ #[tokio::test]
+ async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() {
+ let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
@@ -10802,6 +12905,25 @@ guardian_approval = true
);
}
+ #[tokio::test]
+ async fn respawn_immediate_exit_returns_respawn_requested() {
+ let mut app = make_test_app().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+
+ let control = app
+ .handle_exit_mode(&mut app_server, ExitMode::RespawnImmediate)
+ .await;
+
+ assert_eq!(app.pending_shutdown_exit_thread_id, None);
+ assert!(matches!(
+ control,
+ AppRunControl::Exit(ExitReason::RespawnRequested)
+ ));
+ }
+
#[tokio::test]
async fn interrupt_without_active_turn_is_treated_as_handled() {
let mut app = make_test_app().await;
@@ -10820,6 +12942,34 @@ guardian_approval = true
assert_eq!(handled, true);
}
+ #[tokio::test]
+ async fn closing_active_thread_for_profile_reload_allows_fresh_start() -> Result<()> {
+ let mut app = make_test_app().await;
+ let mut app_server =
+ crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
+ .await
+ .expect("embedded app server");
+ let started = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await
+ .expect("start thread");
+ let thread_id = started.session.thread_id;
+ app.primary_thread_id = Some(thread_id);
+ app.active_thread_id = Some(thread_id);
+
+ app.close_active_thread_for_profile_reload(&mut app_server, thread_id)
+ .await
+ .map_err(|err| color_eyre::eyre::eyre!(err))?;
+
+ assert_eq!(app.active_thread_id, None);
+
+ let restarted = app_server
+ .start_thread(app.chat_widget.config_ref())
+ .await?;
+ assert_ne!(restarted.session.thread_id, thread_id);
+ Ok(())
+ }
+
#[tokio::test]
async fn clear_only_ui_reset_preserves_chat_session_state() {
let mut app = make_test_app().await;
diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs
index ef5a061e3..3b9807247 100644
--- a/codex-rs/tui/src/app/app_server_adapter.rs
+++ b/codex-rs/tui/src/app/app_server_adapter.rs
@@ -153,7 +153,7 @@ impl App {
async fn handle_server_notification_event(
&mut self,
- _app_server_client: &AppServerSession,
+ app_server_client: &AppServerSession,
notification: ServerNotification,
) {
match ¬ification {
@@ -189,17 +189,58 @@ impl App {
match server_notification_thread_target(¬ification) {
ServerNotificationThreadTarget::Thread(thread_id) => {
+ let clawbot_turn = match ¬ification {
+ ServerNotification::TurnCompleted(notification) => {
+ Some(notification.turn.clone())
+ }
+ _ => None,
+ };
+ if self.handle_btw_notification(thread_id, ¬ification) {
+ return;
+ }
let result = if self.primary_thread_id == Some(thread_id)
|| self.primary_thread_id.is_none()
{
- self.enqueue_primary_thread_notification(notification).await
+ self.enqueue_primary_thread_notification(notification.clone())
+ .await
} else {
- self.enqueue_thread_notification(thread_id, notification)
+ self.enqueue_thread_notification(thread_id, notification.clone())
.await
};
if let Err(err) = result {
tracing::warn!("failed to enqueue app-server notification: {err}");
+ } else if self.active_thread_id != Some(thread_id)
+ && let Some(last_agent_message) = super::workflow_after_turn_last_agent_message(
+ self.primary_thread_id,
+ thread_id,
+ ¬ification,
+ )
+ {
+ let _ = self
+ .handle_primary_thread_turn_complete_for_workflows(
+ app_server_client,
+ last_agent_message,
+ )
+ .await;
+ }
+ let workflow_sender = self
+ .workflow_thread_notification_channels
+ .lock()
+ .await
+ .get(&thread_id)
+ .cloned();
+ if let Some(sender) = workflow_sender
+ && sender.send(notification.clone()).is_err()
+ {
+ self.workflow_thread_notification_channels
+ .lock()
+ .await
+ .remove(&thread_id);
+ }
+ if let Some(turn) = clawbot_turn {
+ self.app_event_tx
+ .send(AppEvent::ClawbotTurnCompleted { thread_id, turn });
}
return;
}
@@ -251,6 +292,23 @@ impl App {
return;
};
+ if let Some(reason) = self.reject_btw_request(thread_id, &request) {
+ if let Err(err) = self
+ .reject_app_server_request(app_server_client, request.id().clone(), reason)
+ .await
+ {
+ tracing::warn!("{err}");
+ }
+ return;
+ }
+
+ if self
+ .maybe_auto_resolve_clawbot_server_request(app_server_client, thread_id, &request)
+ .await
+ {
+ return;
+ }
+
let result =
if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() {
self.enqueue_primary_thread_request(request).await
diff --git a/codex-rs/tui/src/app/btw.rs b/codex-rs/tui/src/app/btw.rs
new file mode 100644
index 000000000..b962e8045
--- /dev/null
+++ b/codex-rs/tui/src/app/btw.rs
@@ -0,0 +1,785 @@
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
+use codex_app_server_protocol::ClientRequest;
+use codex_app_server_protocol::HookEventName as AppServerHookEventName;
+use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind;
+use codex_app_server_protocol::RequestId;
+use codex_app_server_protocol::SandboxMode;
+use codex_app_server_protocol::ServerNotification;
+use codex_app_server_protocol::ServerRequest;
+use codex_app_server_protocol::ThreadForkParams;
+use codex_app_server_protocol::ThreadForkResponse;
+use codex_app_server_protocol::ThreadStartParams;
+use codex_app_server_protocol::ThreadStartResponse;
+use codex_app_server_protocol::TurnStatus;
+use codex_protocol::ThreadId;
+use codex_protocol::protocol::AskForApproval;
+use codex_protocol::protocol::SandboxPolicy;
+use codex_protocol::user_input::UserInput;
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::Wrap;
+use uuid::Uuid;
+
+use super::App;
+use crate::app_event::AppEvent;
+use crate::app_server_session::AppServerSession;
+use crate::bottom_pane::SelectionItem;
+use crate::bottom_pane::SelectionViewParams;
+use crate::bottom_pane::SideContentWidth;
+use crate::bottom_pane::popup_consts::standard_popup_hint_line;
+
+const BTW_DEVELOPER_INSTRUCTIONS: &str = concat!(
+ "This is a hidden `/btw` discussion thread. ",
+ "Treat it as a temporary scratchpad that must not mutate the workspace or persistent state. ",
+ "Do not write files, apply patches, spawn agents, or perform side-effectful actions. ",
+ "If you need to inspect local context, keep it read-only and concise. ",
+ "Your answer will be shown to the user in a temporary confirmation view and may be inserted ",
+ "back into the main composer."
+);
+const BTW_DISCUSSION_VIEW_ID: &str = "btw-discussion";
+const PREVIEW_CHAR_LIMIT: usize = 1_200;
+const SUMMARY_MAX_LINES: usize = 4;
+const SUMMARY_MAX_CHARS: usize = 500;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct BtwSessionState {
+ pub(crate) thread_id: ThreadId,
+ pub(crate) final_message: Option,
+ pub(crate) last_status: Option,
+}
+
+impl App {
+ pub(crate) async fn start_btw_discussion(
+ &mut self,
+ app_server: &mut AppServerSession,
+ prompt: String,
+ ) {
+ if self.btw_session.is_some() {
+ self.chat_widget.add_info_message(
+ "A `/btw` discussion is already active.".to_string(),
+ Some("Finish or discard it before starting another one.".to_string()),
+ );
+ return;
+ }
+
+ let trimmed_prompt = prompt.trim();
+ if trimmed_prompt.is_empty() {
+ self.chat_widget
+ .add_error_message("Usage: /btw ".to_string());
+ return;
+ }
+
+ self.open_btw_loading_popup(/*status_message*/ None);
+
+ let thread_id = match self.start_btw_thread(app_server).await {
+ Ok(thread_id) => thread_id,
+ Err(err) => {
+ self.open_btw_failure_popup(&format!("Failed to start `/btw`: {err}"));
+ return;
+ }
+ };
+ self.btw_session = Some(BtwSessionState {
+ thread_id,
+ final_message: None,
+ last_status: None,
+ });
+
+ let turn_result = app_server
+ .turn_start(
+ thread_id,
+ btw_turn_input(trimmed_prompt),
+ self.btw_turn_cwd_path(app_server),
+ AskForApproval::Never,
+ self.config.approvals_reviewer,
+ SandboxPolicy::new_read_only_policy(),
+ self.chat_widget.current_model().to_string(),
+ self.chat_widget.current_reasoning_effort(),
+ /*summary*/ None,
+ self.chat_widget.current_service_tier().map(Some),
+ /*collaboration_mode*/ None,
+ self.config.personality,
+ /*output_schema*/ None,
+ )
+ .await;
+ if let Err(err) = turn_result {
+ self.close_btw_session(app_server).await;
+ self.open_btw_failure_popup(&format!("Failed to submit `/btw`: {err}"));
+ }
+ }
+
+ pub(crate) fn finish_btw_discussion(
+ &mut self,
+ thread_id: ThreadId,
+ result: Result,
+ ) {
+ let Some(session) = self.btw_session.as_mut() else {
+ return;
+ };
+ if session.thread_id != thread_id {
+ return;
+ }
+
+ match result {
+ Ok(message) => {
+ session.final_message = Some(message.clone());
+ self.open_btw_result_popup(&message);
+ }
+ Err(err) => {
+ let message = btw_failure_message(&err, session.last_status.as_deref());
+ self.open_btw_failure_popup(&message);
+ }
+ }
+ }
+
+ pub(crate) fn reject_btw_request(
+ &mut self,
+ thread_id: ThreadId,
+ request: &ServerRequest,
+ ) -> Option {
+ let session = self.btw_session.as_mut()?;
+ if session.thread_id != thread_id {
+ return None;
+ }
+
+ let reason = btw_request_rejection_reason(request);
+ session.last_status = Some(reason.clone());
+ self.open_btw_failure_popup(&format!("`/btw` cannot continue: {reason}"));
+ Some(reason)
+ }
+
+ pub(crate) fn handle_btw_notification(
+ &mut self,
+ thread_id: ThreadId,
+ notification: &ServerNotification,
+ ) -> bool {
+ if self.btw_closing_thread_ids.contains(&thread_id) {
+ if matches!(notification, ServerNotification::ThreadClosed(_)) {
+ self.btw_closing_thread_ids.remove(&thread_id);
+ }
+ return true;
+ }
+
+ let Some(session) = self.btw_session.as_mut() else {
+ return false;
+ };
+ if session.thread_id != thread_id {
+ return false;
+ }
+
+ let status_message = if let Some(status) = btw_status_message(notification)
+ && session.final_message.is_none()
+ && session.last_status.as_deref() != Some(status.as_str())
+ {
+ session.last_status = Some(status.clone());
+ Some(status)
+ } else {
+ None
+ };
+ let final_message_recorded = session.final_message.is_some();
+
+ if let Some(status_message) = status_message.as_deref() {
+ self.open_btw_loading_popup(Some(status_message));
+ }
+
+ if final_message_recorded {
+ return true;
+ }
+
+ match notification {
+ ServerNotification::Error(notification) if !notification.will_retry => {
+ self.app_event_tx.send(AppEvent::BtwCompleted {
+ thread_id,
+ result: Err(notification.error.message.clone()),
+ });
+ }
+ ServerNotification::TurnCompleted(notification) => {
+ let result = match notification.turn.status {
+ TurnStatus::Completed => last_agent_message_or_error(¬ification.turn),
+ TurnStatus::Failed => last_agent_message_or_error(¬ification.turn)
+ .or_else(|_| turn_failed_error(¬ification.turn)),
+ TurnStatus::Interrupted => {
+ Err("Temporary discussion was interrupted.".to_string())
+ }
+ TurnStatus::InProgress => return true,
+ };
+ self.app_event_tx
+ .send(AppEvent::BtwCompleted { thread_id, result });
+ }
+ ServerNotification::ThreadClosed(_) => {
+ self.app_event_tx.send(AppEvent::BtwCompleted {
+ thread_id,
+ result: Err("Temporary discussion closed before a final answer.".to_string()),
+ });
+ }
+ _ => {}
+ }
+
+ true
+ }
+
+ pub(crate) async fn insert_btw_summary(&mut self, app_server: &mut AppServerSession) {
+ let Some(message) = self
+ .btw_session
+ .as_ref()
+ .and_then(|session| session.final_message.as_deref())
+ else {
+ self.open_btw_failure_popup("`/btw` summary is unavailable.");
+ return;
+ };
+
+ self.insert_btw_text(summarize_message(message));
+ self.chat_widget.add_info_message(
+ "Inserted `/btw` summary into the composer.".to_string(),
+ /*hint*/ None,
+ );
+ self.close_btw_session(app_server).await;
+ }
+
+ pub(crate) async fn insert_btw_full(&mut self, app_server: &mut AppServerSession) {
+ let Some(message) = self
+ .btw_session
+ .as_ref()
+ .and_then(|session| session.final_message.as_deref())
+ else {
+ self.open_btw_failure_popup("`/btw` answer is unavailable.");
+ return;
+ };
+
+ self.insert_btw_text(full_insert_text(message));
+ self.chat_widget.add_info_message(
+ "Inserted `/btw` answer into the composer.".to_string(),
+ /*hint*/ None,
+ );
+ self.close_btw_session(app_server).await;
+ }
+
+ pub(crate) async fn discard_btw_session(&mut self, app_server: &mut AppServerSession) {
+ let had_session = self.btw_session.is_some();
+ self.close_btw_session(app_server).await;
+ if had_session {
+ self.chat_widget.add_info_message(
+ "Discarded `/btw` discussion.".to_string(),
+ /*hint*/ None,
+ );
+ }
+ }
+
+ fn insert_btw_text(&mut self, text: String) {
+ if !self
+ .chat_widget
+ .composer_text_with_pending()
+ .trim()
+ .is_empty()
+ {
+ self.chat_widget.insert_str("\n\n");
+ }
+ self.chat_widget.insert_str(&text);
+ }
+
+ pub(crate) async fn close_btw_session(&mut self, app_server: &mut AppServerSession) {
+ let Some(session) = self.btw_session.take() else {
+ return;
+ };
+ self.close_btw_thread(app_server, session.thread_id).await;
+ }
+
+ async fn close_btw_thread(&mut self, app_server: &mut AppServerSession, thread_id: ThreadId) {
+ if !self.btw_closing_thread_ids.insert(thread_id) {
+ return;
+ }
+ if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
+ tracing::warn!(thread_id = %thread_id, error = %err, "failed to close `/btw` thread");
+ }
+ }
+
+ async fn start_btw_thread(&self, app_server: &AppServerSession) -> Result {
+ let request_handle = app_server.request_handle();
+ if let Some(thread_id) = self.chat_widget.thread_id() {
+ let response: ThreadForkResponse = request_handle
+ .request_typed(ClientRequest::ThreadFork {
+ request_id: request_id(),
+ params: btw_thread_fork_params(self, thread_id, app_server),
+ })
+ .await
+ .map_err(|err| format!("failed to fork temporary thread: {err}"))?;
+ ThreadId::from_string(&response.thread.id)
+ .map_err(|err| format!("invalid `/btw` thread id: {err}"))
+ } else {
+ let response: ThreadStartResponse = request_handle
+ .request_typed(ClientRequest::ThreadStart {
+ request_id: request_id(),
+ params: btw_thread_start_params(self, app_server),
+ })
+ .await
+ .map_err(|err| format!("failed to start temporary thread: {err}"))?;
+ ThreadId::from_string(&response.thread.id)
+ .map_err(|err| format!("invalid `/btw` thread id: {err}"))
+ }
+ }
+
+ fn open_btw_loading_popup(&mut self, status_message: Option<&str>) {
+ let status_message = status_message.map(ToOwned::to_owned);
+ self.show_btw_popup(move || btw_loading_view_params(status_message.as_deref()));
+ }
+
+ fn open_btw_result_popup(&mut self, message: &str) {
+ self.show_btw_popup(|| btw_result_view_params(message));
+ }
+
+ fn open_btw_failure_popup(&mut self, error: &str) {
+ self.show_btw_popup(|| btw_failure_view_params(error));
+ }
+
+ fn show_btw_popup(&mut self, build: F)
+ where
+ F: Fn() -> SelectionViewParams,
+ {
+ if self
+ .chat_widget
+ .selected_index_for_active_view(BTW_DISCUSSION_VIEW_ID)
+ .is_some()
+ {
+ let _ = self
+ .chat_widget
+ .replace_selection_view_if_active(BTW_DISCUSSION_VIEW_ID, build());
+ } else {
+ self.chat_widget.show_selection_view(build());
+ }
+ }
+
+ fn btw_thread_cwd(&self, app_server: &AppServerSession) -> Option {
+ if app_server.is_remote() {
+ app_server
+ .remote_cwd_override()
+ .map(|cwd| cwd.to_string_lossy().to_string())
+ } else {
+ Some(self.config.cwd.to_string_lossy().to_string())
+ }
+ }
+
+ fn btw_turn_cwd_path(&self, app_server: &AppServerSession) -> PathBuf {
+ if app_server.is_remote() {
+ app_server
+ .remote_cwd_override()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| self.config.cwd.to_path_buf())
+ } else {
+ self.config.cwd.to_path_buf()
+ }
+ }
+}
+
+fn request_id() -> RequestId {
+ RequestId::String(format!("btw-{}", Uuid::new_v4()))
+}
+
+fn btw_turn_input(prompt: &str) -> Vec {
+ vec![UserInput::Text {
+ text: prompt.to_string(),
+ text_elements: Vec::new(),
+ }]
+}
+
+fn btw_thread_start_params(app: &App, app_server: &AppServerSession) -> ThreadStartParams {
+ ThreadStartParams {
+ model: Some(app.chat_widget.current_model().to_string()),
+ model_provider: (!app_server.is_remote()).then_some(app.config.model_provider_id.clone()),
+ cwd: app.btw_thread_cwd(app_server),
+ approval_policy: Some(AskForApproval::Never.into()),
+ approvals_reviewer: Some(AppServerApprovalsReviewer::from(
+ app.config.approvals_reviewer,
+ )),
+ sandbox: Some(SandboxMode::ReadOnly),
+ config: config_overrides(app.active_profile.as_deref()),
+ developer_instructions: Some(merge_developer_instructions(
+ app.config.developer_instructions.as_deref(),
+ )),
+ personality: app.config.personality,
+ ephemeral: Some(true),
+ persist_extended_history: true,
+ ..ThreadStartParams::default()
+ }
+}
+
+fn btw_thread_fork_params(
+ app: &App,
+ thread_id: ThreadId,
+ app_server: &AppServerSession,
+) -> ThreadForkParams {
+ ThreadForkParams {
+ thread_id: thread_id.to_string(),
+ model: Some(app.chat_widget.current_model().to_string()),
+ model_provider: (!app_server.is_remote()).then_some(app.config.model_provider_id.clone()),
+ cwd: app.btw_thread_cwd(app_server),
+ approval_policy: Some(AskForApproval::Never.into()),
+ approvals_reviewer: Some(AppServerApprovalsReviewer::from(
+ app.config.approvals_reviewer,
+ )),
+ sandbox: Some(SandboxMode::ReadOnly),
+ config: config_overrides(app.active_profile.as_deref()),
+ developer_instructions: Some(merge_developer_instructions(
+ app.config.developer_instructions.as_deref(),
+ )),
+ ephemeral: true,
+ persist_extended_history: true,
+ ..ThreadForkParams::default()
+ }
+}
+
+fn config_overrides(active_profile: Option<&str>) -> Option> {
+ active_profile.map(|profile| {
+ HashMap::from([(
+ "profile".to_string(),
+ serde_json::Value::String(profile.to_string()),
+ )])
+ })
+}
+
+fn merge_developer_instructions(existing: Option<&str>) -> String {
+ match existing {
+ Some(existing) if !existing.trim().is_empty() => {
+ format!("{existing}\n\n{BTW_DEVELOPER_INSTRUCTIONS}")
+ }
+ _ => BTW_DEVELOPER_INSTRUCTIONS.to_string(),
+ }
+}
+
+fn preview_text(message: &str) -> String {
+ let trimmed = message.trim();
+ if trimmed.chars().count() <= PREVIEW_CHAR_LIMIT {
+ return trimmed.to_string();
+ }
+
+ let preview: String = trimmed.chars().take(PREVIEW_CHAR_LIMIT).collect();
+ format!("{preview}\n\n...preview truncated...")
+}
+
+fn summarize_message(message: &str) -> String {
+ let mut kept = Vec::new();
+ let mut used_chars = 0usize;
+ for line in message
+ .lines()
+ .map(str::trim)
+ .filter(|line| !line.is_empty())
+ {
+ let next_len = used_chars.saturating_add(line.chars().count());
+ if !kept.is_empty() && (kept.len() >= SUMMARY_MAX_LINES || next_len > SUMMARY_MAX_CHARS) {
+ break;
+ }
+ kept.push(line.to_string());
+ used_chars = next_len;
+ }
+
+ if kept.is_empty() {
+ "BTW summary:\n(Empty answer)".to_string()
+ } else {
+ format!("BTW summary:\n{}", kept.join("\n"))
+ }
+}
+
+fn full_insert_text(message: &str) -> String {
+ format!("BTW discussion:\n{message}")
+}
+
+fn last_agent_message_for_turn(turn: &codex_app_server_protocol::Turn) -> Option {
+ turn.items.iter().rev().find_map(|item| match item {
+ codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } => Some(text.clone()),
+ _ => None,
+ })
+}
+
+fn last_agent_message_or_error(turn: &codex_app_server_protocol::Turn) -> Result {
+ last_agent_message_for_turn(turn)
+ .ok_or_else(|| "Temporary discussion finished without a final answer.".to_string())
+}
+
+fn turn_failed_error(turn: &codex_app_server_protocol::Turn) -> Result {
+ Err(turn
+ .error
+ .as_ref()
+ .map(|error| error.message.clone())
+ .unwrap_or_else(|| "Temporary discussion failed without a final error.".to_string()))
+}
+
+fn btw_loading_view_params(status_message: Option<&str>) -> SelectionViewParams {
+ let mut body = String::new();
+ if let Some(status_message) = status_message.filter(|status| !status.trim().is_empty()) {
+ body.push_str("Current hidden status:\n");
+ body.push_str(status_message);
+ body.push_str("\n\n");
+ }
+ body.push_str(
+ "Codex is answering in a hidden ephemeral thread. Nothing will be written back to the \
+ main composer unless you explicitly choose an insert action.",
+ );
+ SelectionViewParams {
+ view_id: Some(BTW_DISCUSSION_VIEW_ID),
+ title: Some("Temporary BTW discussion".to_string()),
+ subtitle: Some("Running a hidden temporary discussion thread.".to_string()),
+ footer_hint: Some(standard_popup_hint_line()),
+ items: vec![SelectionItem {
+ name: "Discard".to_string(),
+ description: Some("Cancel and destroy the temporary discussion.".to_string()),
+ actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))],
+ dismiss_on_select: true,
+ ..Default::default()
+ }],
+ side_content: Paragraph::new(body).wrap(Wrap { trim: false }).into(),
+ side_content_width: SideContentWidth::Half,
+ side_content_min_width: 28,
+ on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))),
+ ..Default::default()
+ }
+}
+
+fn btw_failure_message(error: &str, last_status: Option<&str>) -> String {
+ let mut message = format!("`/btw` failed: {error}");
+ if let Some(last_status) = last_status.filter(|status| !status.trim().is_empty())
+ && !message.contains(last_status)
+ {
+ message.push_str("\n\nLast hidden status:\n");
+ message.push_str(last_status);
+ }
+ message
+}
+
+fn btw_request_rejection_reason(request: &ServerRequest) -> String {
+ match request {
+ ServerRequest::ToolRequestUserInput { .. } => {
+ "the hidden temporary discussion asked for interactive user input. Run the prompt in the main thread instead.".to_string()
+ }
+ ServerRequest::CommandExecutionRequestApproval { .. } => {
+ "the hidden temporary discussion requested command approval, which `/btw` does not support.".to_string()
+ }
+ ServerRequest::FileChangeRequestApproval { .. } => {
+ "the hidden temporary discussion tried to change files, which `/btw` does not allow.".to_string()
+ }
+ ServerRequest::PermissionsRequestApproval { .. } => {
+ "the hidden temporary discussion requested extra permissions, which `/btw` does not allow.".to_string()
+ }
+ ServerRequest::McpServerElicitationRequest { .. } => {
+ "the hidden temporary discussion requested MCP interaction, which `/btw` cannot surface.".to_string()
+ }
+ ServerRequest::DynamicToolCall { .. } => {
+ "the hidden temporary discussion required an unsupported client-side tool call.".to_string()
+ }
+ ServerRequest::ChatgptAuthTokensRefresh { .. }
+ | ServerRequest::ApplyPatchApproval { .. }
+ | ServerRequest::ExecCommandApproval { .. } => {
+ "the hidden temporary discussion required an unsupported client-side request.".to_string()
+ }
+ }
+}
+
+fn btw_status_message(notification: &ServerNotification) -> Option {
+ match notification {
+ ServerNotification::HookStarted(notification) => {
+ let label = hook_event_label(notification.run.event_name);
+ Some(match notification.run.status_message.as_deref() {
+ Some(status_message) if !status_message.is_empty() => {
+ format!("Running {label} hook: {status_message}")
+ }
+ _ => format!("Running {label} hook"),
+ })
+ }
+ ServerNotification::HookCompleted(notification) => {
+ let label = hook_event_label(notification.run.event_name);
+ let status = format!("{:?}", notification.run.status).to_lowercase();
+ let mut message = format!("{label} hook ({status})");
+ if let Some(entry) = notification.run.entries.iter().find(|entry| {
+ matches!(
+ entry.kind,
+ AppServerHookOutputEntryKind::Warning
+ | AppServerHookOutputEntryKind::Stop
+ | AppServerHookOutputEntryKind::Error
+ )
+ }) {
+ message.push_str(": ");
+ message.push_str(&entry.text);
+ }
+ Some(message)
+ }
+ ServerNotification::Error(notification) => Some(notification.error.message.clone()),
+ _ => None,
+ }
+}
+
+fn hook_event_label(event_name: AppServerHookEventName) -> &'static str {
+ match event_name {
+ AppServerHookEventName::PreToolUse => "PreToolUse",
+ AppServerHookEventName::PostToolUse => "PostToolUse",
+ AppServerHookEventName::SessionStart => "SessionStart",
+ AppServerHookEventName::UserPromptSubmit => "UserPromptSubmit",
+ AppServerHookEventName::Stop => "Stop",
+ }
+}
+
+fn btw_result_view_params(message: &str) -> SelectionViewParams {
+ SelectionViewParams {
+ view_id: Some(BTW_DISCUSSION_VIEW_ID),
+ title: Some("Temporary BTW answer".to_string()),
+ subtitle: Some("Choose what to do with the temporary answer.".to_string()),
+ footer_hint: Some(standard_popup_hint_line()),
+ items: vec![
+ SelectionItem {
+ name: "Insert Summary".to_string(),
+ description: Some("Insert a short summary into the main composer.".to_string()),
+ actions: vec![Box::new(|tx| tx.send(AppEvent::BtwInsertSummary))],
+ dismiss_on_select: true,
+ ..Default::default()
+ },
+ SelectionItem {
+ name: "Insert Full".to_string(),
+ description: Some("Insert the full answer into the main composer.".to_string()),
+ actions: vec![Box::new(|tx| tx.send(AppEvent::BtwInsertFull))],
+ dismiss_on_select: true,
+ ..Default::default()
+ },
+ SelectionItem {
+ name: "Discard".to_string(),
+ description: Some(
+ "Destroy the temporary discussion and keep the main composer untouched."
+ .to_string(),
+ ),
+ actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))],
+ dismiss_on_select: true,
+ ..Default::default()
+ },
+ ],
+ side_content: Paragraph::new(preview_text(message))
+ .wrap(Wrap { trim: false })
+ .into(),
+ side_content_width: SideContentWidth::Half,
+ side_content_min_width: 28,
+ on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))),
+ ..Default::default()
+ }
+}
+
+fn btw_failure_view_params(error: &str) -> SelectionViewParams {
+ SelectionViewParams {
+ view_id: Some(BTW_DISCUSSION_VIEW_ID),
+ title: Some("Temporary BTW failed".to_string()),
+ subtitle: Some("The hidden temporary discussion did not complete.".to_string()),
+ footer_hint: Some(standard_popup_hint_line()),
+ items: vec![SelectionItem {
+ name: "Close".to_string(),
+ description: Some("Dismiss this temporary discussion.".to_string()),
+ actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))],
+ dismiss_on_select: true,
+ ..Default::default()
+ }],
+ side_content: Paragraph::new(error.to_string())
+ .wrap(Wrap { trim: false })
+ .into(),
+ side_content_width: SideContentWidth::Half,
+ side_content_min_width: 28,
+ on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))),
+ ..Default::default()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use insta::assert_snapshot;
+ use pretty_assertions::assert_eq;
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+ use ratatui::layout::Rect;
+ use tokio::sync::mpsc::unbounded_channel;
+
+ use super::btw_failure_view_params;
+ use super::btw_loading_view_params;
+ use super::btw_result_view_params;
+ use super::merge_developer_instructions;
+ use super::preview_text;
+ use super::summarize_message;
+ use crate::app_event::AppEvent;
+ use crate::app_event_sender::AppEventSender;
+ use crate::bottom_pane::ListSelectionView;
+ use crate::render::renderable::Renderable;
+
+ fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
+ terminal
+ .draw(|frame| {
+ let area = Rect::new(0, 0, width, height);
+ view.render(area, frame.buffer_mut());
+ })
+ .expect("draw popup");
+ format!("{:?}", terminal.backend())
+ }
+
+ #[test]
+ fn btw_loading_popup_snapshot() {
+ let (tx_raw, _rx) = unbounded_channel::();
+ let tx = AppEventSender::new(tx_raw);
+ let view = ListSelectionView::new(btw_loading_view_params(/*status_message*/ None), tx);
+
+ assert_snapshot!(
+ "btw_loading_popup",
+ render_selection_popup(&view, /*width*/ 92, /*height*/ 20)
+ );
+ }
+
+ #[test]
+ fn btw_result_popup_snapshot() {
+ let (tx_raw, _rx) = unbounded_channel::();
+ let tx = AppEventSender::new(tx_raw);
+ let view = ListSelectionView::new(
+ btw_result_view_params(
+ "Use a hidden thread to brainstorm tradeoffs, then choose whether to insert the \
+ summary or the full answer back into the main composer.",
+ ),
+ tx,
+ );
+
+ assert_snapshot!(
+ "btw_result_popup",
+ render_selection_popup(&view, /*width*/ 92, /*height*/ 28)
+ );
+ }
+
+ #[test]
+ fn btw_failure_popup_snapshot() {
+ let (tx_raw, _rx) = unbounded_channel::();
+ let tx = AppEventSender::new(tx_raw);
+ let view = ListSelectionView::new(
+ btw_failure_view_params("`/btw` failed: upstream unavailable"),
+ tx,
+ );
+
+ assert_snapshot!(
+ "btw_failure_popup",
+ render_selection_popup(&view, /*width*/ 92, /*height*/ 20)
+ );
+ }
+
+ #[test]
+ fn summarize_btw_message_keeps_short_prefix_for_insertion() {
+ let summary = summarize_message(
+ "First point.\n\nSecond point.\nThird point.\nFourth point.\nFifth point.",
+ );
+
+ assert_eq!(
+ summary,
+ "BTW summary:\nFirst point.\nSecond point.\nThird point.\nFourth point."
+ );
+ }
+
+ #[test]
+ fn preview_text_truncates_long_messages() {
+ let preview = preview_text(&"a".repeat(1_250));
+ assert!(preview.contains("preview truncated"));
+ }
+
+ #[test]
+ fn merge_developer_instructions_appends_btw_guardrail() {
+ assert_eq!(
+ merge_developer_instructions(Some("Stay focused.")),
+ format!("Stay focused.\n\n{}", super::BTW_DEVELOPER_INSTRUCTIONS)
+ );
+ }
+}
diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs
new file mode 100644
index 000000000..bd3f648a5
--- /dev/null
+++ b/codex-rs/tui/src/app/clawbot.rs
@@ -0,0 +1,914 @@
+use std::collections::HashMap;
+use std::path::Path;
+
+use anyhow::Context;
+use anyhow::Result;
+use codex_app_server_protocol::ServerRequest;
+use codex_app_server_protocol::ThreadStatus;
+use codex_app_server_protocol::TurnStatus;
+use codex_clawbot::CachedUnreadMessage;
+use codex_clawbot::ClawbotRuntime;
+use codex_clawbot::ClawbotStore;
+use codex_clawbot::ClawbotTurnMode;
+use codex_clawbot::FeishuConfig;
+use codex_clawbot::FeishuProviderRuntime;
+use codex_clawbot::PendingClawbotTurn;
+use codex_clawbot::ProviderEvent;
+use codex_clawbot::ProviderMessageRef;
+use codex_clawbot::ProviderOutboundReaction;
+use codex_clawbot::ProviderOutboundTextMessage;
+use codex_clawbot::ProviderSessionRef;
+use codex_clawbot::append_diagnostic_event;
+use codex_clawbot::feishu_failure_reply_text;
+use codex_protocol::ThreadId;
+use codex_protocol::protocol::AskForApproval;
+use codex_protocol::protocol::GranularApprovalConfig;
+use codex_protocol::protocol::Op;
+use codex_protocol::request_permissions::PermissionGrantScope;
+use codex_protocol::request_permissions::RequestPermissionsResponse;
+use codex_protocol::request_user_input::RequestUserInputResponse;
+use codex_protocol::user_input::UserInput;
+use tokio::sync::mpsc;
+
+use super::App;
+use crate::app_command::AppCommand;
+use crate::app_event::AppEvent;
+use crate::app_server_session::AppServerSession;
+use crate::app_server_session::ThreadSessionState;
+
+const FEISHU_AUTO_ACK_EMOJI_TYPE: &str = "TONGUE";
+
+impl App {
+ pub(super) async fn sync_clawbot_workspace(&mut self, app_server: &mut AppServerSession) {
+ if let Err(err) = self.sync_clawbot_workspace_inner(app_server).await {
+ tracing::warn!(error = %err, "failed to sync clawbot workspace");
+ self.chat_widget
+ .add_error_message(format!("Clawbot workspace sync failed: {err}"));
+ }
+ }
+
+ async fn sync_clawbot_workspace_inner(
+ &mut self,
+ app_server: &mut AppServerSession,
+ ) -> Result<()> {
+ let workspace_root = self.config.cwd.to_path_buf();
+ let workspace_changed = self.clawbot_workspace_root.as_ref() != Some(&workspace_root);
+ if workspace_changed {
+ self.abort_clawbot_provider_runtime();
+ self.clawbot_workspace_root = Some(workspace_root.clone());
+ self.clawbot_pending_turns.clear();
+ self.refresh_clawbot_provider_runtime()?;
+ if let Ok(mut runtime) = ClawbotRuntime::load(workspace_root.clone())
+ && runtime.snapshot().config.feishu.is_some()
+ && let Err(err) = runtime.scan_feishu_sessions().await
+ {
+ tracing::warn!(error = %err, "failed to refresh clawbot Feishu sessions");
+ }
+ }
+ self.restore_clawbot_pending_turns()?;
+ self.reconcile_clawbot_pending_turns(app_server).await?;
+
+ let runtime = ClawbotRuntime::load(workspace_root)?;
+ let sessions_to_drain = runtime
+ .snapshot()
+ .sessions
+ .iter()
+ .filter(|session| session.bound_thread_id.is_some())
+ .filter(|session| session.unread_count > 0)
+ .map(codex_clawbot::ProviderSession::session_ref)
+ .collect::>();
+ for session in sessions_to_drain {
+ self.dispatch_next_clawbot_message(app_server, &session)
+ .await?;
+ }
+ Ok(())
+ }
+
+ fn clawbot_store(&self) -> Result {
+ let workspace_root = self
+ .clawbot_workspace_root
+ .clone()
+ .or_else(|| Some(self.config.cwd.to_path_buf()))
+ .context("missing clawbot workspace root")?;
+ Ok(ClawbotStore::new(workspace_root))
+ }
+
+ fn restore_clawbot_pending_turns(&mut self) -> Result<()> {
+ let pending_turns = self.clawbot_store()?.load_pending_turns()?;
+ self.clawbot_pending_turns.clear();
+ for pending_turn in pending_turns {
+ let thread_id = ThreadId::from_string(&pending_turn.thread_id).with_context(|| {
+ format!("invalid clawbot thread id `{}`", pending_turn.thread_id)
+ })?;
+ self.clawbot_pending_turns
+ .entry(thread_id)
+ .or_default()
+ .push_back(pending_turn);
+ }
+ Ok(())
+ }
+
+ async fn reconcile_clawbot_pending_turns(
+ &mut self,
+ app_server: &mut AppServerSession,
+ ) -> Result<()> {
+ let pending_turns = self
+ .clawbot_pending_turns
+ .values()
+ .flat_map(|queue| queue.iter().cloned())
+ .collect::>();
+ for pending_turn in pending_turns {
+ let thread_id = ThreadId::from_string(&pending_turn.thread_id).with_context(|| {
+ format!("invalid clawbot thread id `{}`", pending_turn.thread_id)
+ })?;
+ let thread = match app_server
+ .thread_read(thread_id, /*include_turns*/ true)
+ .await
+ {
+ Ok(thread) => thread,
+ Err(err) => {
+ tracing::warn!(
+ thread_id = pending_turn.thread_id,
+ turn_id = pending_turn.turn_id,
+ error = %err,
+ "failed to reconcile clawbot pending turn; clearing stale entry"
+ );
+ let _ = self.remove_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?;
+ continue;
+ }
+ };
+ let Some(turn) = thread
+ .turns
+ .iter()
+ .find(|turn| turn.id == pending_turn.turn_id)
+ .cloned()
+ else {
+ if !matches!(thread.status, ThreadStatus::Active { .. }) {
+ let _ = self.remove_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?;
+ }
+ continue;
+ };
+ match turn.status {
+ TurnStatus::Completed | TurnStatus::Failed | TurnStatus::Interrupted => {
+ self.handle_clawbot_turn_completed(app_server, thread_id, turn)
+ .await?;
+ }
+ TurnStatus::InProgress => {
+ self.attach_clawbot_bound_thread_if_needed(app_server, thread_id)
+ .await?;
+ if let Some(channel) = self.thread_event_channels.get(&thread_id) {
+ let mut store = channel.store.lock().await;
+ store.active_turn_id = Some(turn.id);
+ }
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn start_clawbot_provider_runtime(&mut self, workspace_root: &Path, config: FeishuConfig) {
+ self.abort_clawbot_provider_runtime();
+ let app_event_tx = self.app_event_tx.clone();
+ let workspace = workspace_root.display().to_string();
+ let workspace_root = workspace_root.to_path_buf();
+ self.clawbot_provider_task = Some(tokio::spawn(async move {
+ let (provider_event_tx, mut provider_event_rx) = mpsc::unbounded_channel();
+ let forward_task = tokio::spawn(async move {
+ while let Some(event) = provider_event_rx.recv().await {
+ app_event_tx.send(AppEvent::ClawbotProviderEvent { event });
+ }
+ });
+ if let Err(err) = FeishuProviderRuntime::new(workspace_root.to_path_buf(), config)
+ .run(provider_event_tx)
+ .await
+ {
+ tracing::warn!(workspace, error = %err, "clawbot provider runtime exited");
+ }
+ forward_task.abort();
+ let _ = forward_task.await;
+ }));
+ }
+
+ pub(super) fn abort_clawbot_provider_runtime(&mut self) {
+ if let Some(handle) = self.clawbot_provider_task.take() {
+ handle.abort();
+ }
+ }
+
+ pub(super) fn refresh_clawbot_provider_runtime(&mut self) -> Result<()> {
+ let workspace_root = self.config.cwd.to_path_buf();
+ self.clawbot_workspace_root = Some(workspace_root.clone());
+ let runtime = ClawbotRuntime::load(workspace_root.clone())?;
+ if let Some(feishu) = runtime.snapshot().config.feishu.clone()
+ && feishu.has_api_credentials()
+ {
+ self.start_clawbot_provider_runtime(workspace_root.as_path(), feishu);
+ } else {
+ self.abort_clawbot_provider_runtime();
+ }
+ Ok(())
+ }
+
+ pub(super) async fn handle_clawbot_provider_event(
+ &mut self,
+ app_server: &mut AppServerSession,
+ event: ProviderEvent,
+ ) -> Result<()> {
+ let Some(workspace_root) = self.clawbot_workspace_root.clone() else {
+ return Ok(());
+ };
+ let session_to_drain = match &event {
+ ProviderEvent::InboundMessage(message) => {
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "bridge.inbound_message_received",
+ serde_json::json!({
+ "session_id": message.session.session_id,
+ "message_id": message.message_id,
+ "text": message.text,
+ }),
+ );
+ Some(message.session.clone())
+ }
+ _ => None,
+ };
+ let mut runtime = ClawbotRuntime::load(workspace_root)?;
+ runtime.apply_provider_event(event)?;
+ if let Some(session) = session_to_drain {
+ self.dispatch_next_clawbot_message(app_server, &session)
+ .await?;
+ }
+ Ok(())
+ }
+
+ pub(super) async fn handle_clawbot_turn_completed(
+ &mut self,
+ app_server: &mut AppServerSession,
+ thread_id: ThreadId,
+ turn: codex_app_server_protocol::Turn,
+ ) -> Result<()> {
+ let Some(workspace_root) = self.clawbot_workspace_root.clone() else {
+ return Ok(());
+ };
+ let next_session = if let Some(pending) =
+ self.take_pending_clawbot_turn(thread_id, &turn.id)?
+ {
+ let reply_text = if let Some(text) = clawbot_outbound_text_for_turn(&turn) {
+ Some(text)
+ } else if matches!(
+ turn.status,
+ codex_app_server_protocol::TurnStatus::Completed
+ | codex_app_server_protocol::TurnStatus::Failed
+ | codex_app_server_protocol::TurnStatus::Interrupted
+ ) {
+ match app_server
+ .thread_read(thread_id, /*include_turns*/ true)
+ .await
+ {
+ Ok(thread) => thread
+ .turns
+ .iter()
+ .find(|thread_turn| thread_turn.id == turn.id)
+ .and_then(clawbot_outbound_text_for_turn),
+ Err(err) => {
+ tracing::warn!(
+ thread_id = %thread_id,
+ turn_id = turn.id,
+ error = %err,
+ "failed to load full turn for clawbot outbound reply"
+ );
+ None
+ }
+ }
+ } else {
+ None
+ };
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "bridge.turn_completed",
+ serde_json::json!({
+ "session_id": pending.session.session_id.clone(),
+ "thread_id": thread_id,
+ "turn_id": turn.id,
+ "status": format!("{:?}", turn.status),
+ "reply_text": reply_text.clone(),
+ }),
+ );
+ if let Some(text) = reply_text {
+ self.send_clawbot_thread_reply(workspace_root.as_path(), &pending.session, text)
+ .await?;
+ }
+ let mut runtime = ClawbotRuntime::load(workspace_root.clone())?;
+ let removed = runtime.take_next_unread_message(&pending.session)?;
+ if removed.as_ref().map(|entry| entry.message_id.as_str())
+ != Some(pending.message_id.as_str())
+ {
+ tracing::warn!(
+ session = pending.session.session_id,
+ expected = pending.message_id,
+ actual = removed.as_ref().map(|entry| entry.message_id.as_str()),
+ "clawbot unread FIFO state drifted while completing turn"
+ );
+ }
+ Some(pending.session)
+ } else {
+ let runtime = ClawbotRuntime::load(workspace_root)?;
+ runtime.bound_session_for_thread(&thread_id.to_string())?
+ };
+
+ if let Some(session) = next_session {
+ self.dispatch_next_clawbot_message(app_server, &session)
+ .await?;
+ }
+ Ok(())
+ }
+
+ pub(super) async fn dispatch_next_clawbot_message(
+ &mut self,
+ app_server: &mut AppServerSession,
+ session: &ProviderSessionRef,
+ ) -> Result<()> {
+ let Some(workspace_root) = self.clawbot_workspace_root.clone() else {
+ return Ok(());
+ };
+ let runtime = ClawbotRuntime::load(workspace_root.clone())?;
+ let Some(binding) = runtime.load_binding_for_session(session)? else {
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "bridge.dispatch_skipped",
+ serde_json::json!({
+ "reason": "missing_binding",
+ "session_id": session.session_id,
+ }),
+ );
+ return Ok(());
+ };
+ if !binding.inbound_forwarding_enabled {
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "bridge.dispatch_skipped",
+ serde_json::json!({
+ "reason": "inbound_forwarding_disabled",
+ "session_id": session.session_id,
+ "thread_id": binding.thread_id,
+ }),
+ );
+ return Ok(());
+ }
+ let thread_id = ThreadId::from_string(&binding.thread_id)
+ .with_context(|| format!("invalid clawbot thread id `{}`", binding.thread_id))?;
+ if self.active_turn_id_for_thread(thread_id).await.is_some() {
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "bridge.dispatch_skipped",
+ serde_json::json!({
+ "reason": "thread_busy",
+ "session_id": session.session_id,
+ "thread_id": thread_id,
+ }),
+ );
+ return Ok(());
+ }
+ if self
+ .clawbot_pending_turns
+ .get(&thread_id)
+ .is_some_and(|queue| queue.iter().any(|pending| pending.session == *session))
+ {
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "bridge.dispatch_skipped",
+ serde_json::json!({
+ "reason": "pending_turn",
+ "session_id": session.session_id,
+ "thread_id": thread_id,
+ }),
+ );
+ return Ok(());
+ }
+
+ let Some(message) = next_unread_message_for_session(&runtime, session) else {
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "bridge.dispatch_skipped",
+ serde_json::json!({
+ "reason": "no_unread_message",
+ "session_id": session.session_id,
+ "thread_id": thread_id,
+ }),
+ );
+ return Ok(());
+ };
+ let turn_mode = runtime.snapshot().config.turn_mode;
+ self.attach_clawbot_bound_thread_if_needed(app_server, thread_id)
+ .await?;
+ if let Err(err) = self
+ .send_clawbot_outbound_reaction(ProviderOutboundReaction {
+ target: ProviderMessageRef::new(
+ message.provider,
+ message.session_id.clone(),
+ message.message_id.clone(),
+ ),
+ emoji_type: FEISHU_AUTO_ACK_EMOJI_TYPE.to_string(),
+ })
+ .await
+ {
+ tracing::warn!(
+ thread_id = %thread_id,
+ session = session.session_id,
+ error = %err,
+ "failed to auto-ack clawbot inbound message"
+ );
+ }
+ let turn_id = self
+ .submit_clawbot_message_to_thread(
+ app_server,
+ thread_id,
+ message.text.clone(),
+ turn_mode,
+ )
+ .await?;
+ let _ = append_diagnostic_event(
+ workspace_root.as_path(),
+ "bridge.thread_turn_started",
+ serde_json::json!({
+ "session_id": session.session_id,
+ "thread_id": thread_id,
+ "turn_id": turn_id,
+ "message_id": message.message_id.clone(),
+ "text": message.text.clone(),
+ }),
+ );
+ self.register_pending_clawbot_turn(
+ thread_id,
+ session.clone(),
+ turn_id,
+ message.message_id.clone(),
+ turn_mode,
+ );
+ Ok(())
+ }
+
+ async fn attach_clawbot_bound_thread_if_needed(
+ &mut self,
+ app_server: &mut AppServerSession,
+ thread_id: ThreadId,
+ ) -> Result<()> {
+ if self.thread_event_channels.contains_key(&thread_id) {
+ return Ok(());
+ }
+ if let Err(attach_err) = self
+ .attach_live_thread_for_selection(app_server, thread_id)
+ .await
+ {
+ tracing::warn!(
+ thread_id = %thread_id,
+ error = %attach_err,
+ "falling back to thread/read for clawbot thread attachment"
+ );
+ let thread = app_server
+ .thread_read(thread_id, /*include_turns*/ false)
+ .await
+ .map_err(|err| {
+ anyhow::anyhow!("failed to read clawbot thread {thread_id}: {err}")
+ })?;
+ let session = self.session_state_for_thread_read(thread_id, &thread).await;
+ let channel = self.ensure_thread_channel(thread_id);
+ let mut store = channel.store.lock().await;
+ store.set_session(session, Vec::new());
+ }
+ Ok(())
+ }
+
+ async fn submit_clawbot_message_to_thread(
+ &mut self,
+ app_server: &mut AppServerSession,
+ thread_id: ThreadId,
+ message: String,
+ turn_mode: ClawbotTurnMode,
+ ) -> Result {
+ let trimmed = message.trim().to_string();
+ if trimmed.is_empty() {
+ return Err(anyhow::anyhow!("cannot submit empty clawbot message"));
+ }
+
+ let session = self
+ .clawbot_thread_session(thread_id)
+ .await?
+ .with_context(|| {
+ format!("missing live thread session for clawbot thread {thread_id}")
+ })?;
+ let op: AppCommand = Op::UserTurn {
+ items: vec![UserInput::Text {
+ text: trimmed.clone(),
+ text_elements: Vec::new(),
+ }],
+ cwd: session.cwd.clone(),
+ approval_policy: clawbot_approval_policy(session.approval_policy, turn_mode),
+ approvals_reviewer: Some(session.approvals_reviewer),
+ sandbox_policy: session.sandbox_policy.clone(),
+ model: session.model.clone(),
+ effort: session.reasoning_effort,
+ summary: None,
+ service_tier: Some(session.service_tier),
+ final_output_json_schema: None,
+ collaboration_mode: None,
+ personality: self.config.personality,
+ }
+ .into();
+ crate::session_log::log_outbound_op(&op);
+ let response = app_server
+ .turn_start(
+ thread_id,
+ vec![UserInput::Text {
+ text: trimmed,
+ text_elements: Vec::new(),
+ }],
+ session.cwd,
+ clawbot_approval_policy(session.approval_policy, turn_mode),
+ session.approvals_reviewer,
+ session.sandbox_policy,
+ session.model,
+ session.reasoning_effort,
+ /*summary*/ None,
+ Some(session.service_tier),
+ /*collaboration_mode*/ None,
+ self.config.personality,
+ /*output_schema*/ None,
+ )
+ .await
+ .map_err(|err| {
+ anyhow::anyhow!("failed to start clawbot turn for thread {thread_id}: {err}")
+ })?;
+ self.note_thread_outbound_op(thread_id, &op).await;
+ if let Some(channel) = self.thread_event_channels.get(&thread_id) {
+ let mut store = channel.store.lock().await;
+ store.active_turn_id = Some(response.turn.id.clone());
+ }
+ Ok(response.turn.id)
+ }
+
+ async fn clawbot_thread_session(
+ &self,
+ thread_id: ThreadId,
+ ) -> Result> {
+ let Some(channel) = self.thread_event_channels.get(&thread_id) else {
+ return Ok(None);
+ };
+ let store = channel.store.lock().await;
+ Ok(store.session.clone())
+ }
+
+ async fn send_clawbot_thread_reply(
+ &mut self,
+ workspace_root: &Path,
+ session: &ProviderSessionRef,
+ text: String,
+ ) -> Result<()> {
+ let runtime = ClawbotRuntime::load(workspace_root.to_path_buf())?;
+ let Some(binding) = runtime.load_binding_for_session(session)? else {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "bridge.reply_skipped",
+ serde_json::json!({
+ "reason": "missing_binding",
+ "session_id": session.session_id,
+ "text": text,
+ }),
+ );
+ return Ok(());
+ };
+ if !binding.outbound_forwarding_enabled {
+ let _ = append_diagnostic_event(
+ workspace_root,
+ "bridge.reply_skipped",
+ serde_json::json!({
+ "reason": "outbound_forwarding_disabled",
+ "session_id": session.session_id,
+ "thread_id": binding.thread_id,
+ "text": text,
+ }),
+ );
+ return Ok(());
+ }
+ self.send_clawbot_outbound_text(
+ workspace_root,
+ ProviderOutboundTextMessage {
+ session: session.clone(),
+ text,
+ },
+ )
+ .await
+ }
+
+ async fn send_clawbot_outbound_text(
+ &mut self,
+ workspace_root: &Path,
+ message: ProviderOutboundTextMessage,
+ ) -> Result<()> {
+ #[cfg(test)]
+ {
+ let _ = workspace_root;
+ self.clawbot_outbound_messages.push(message);
+ Ok(())
+ }
+
+ #[cfg(not(test))]
+ {
+ let runtime = ClawbotRuntime::load(workspace_root.to_path_buf())?;
+ let provider = runtime
+ .feishu_provider()
+ .context("missing Feishu config for clawbot outbound bridge")?;
+ let session_id = message.session.session_id.clone();
+ let text = message.text.clone();
+ let send_result = provider.send_text(message).await;
+ let kind = if send_result.is_ok() {
+ "bridge.reply_forwarded"
+ } else {
+ "bridge.reply_forward_failed"
+ };
+ let _ = append_diagnostic_event(
+ workspace_root,
+ kind,
+ serde_json::json!({
+ "session_id": session_id,
+ "text": text,
+ "error": send_result.as_ref().err().map(ToString::to_string),
+ }),
+ );
+ send_result
+ }
+ }
+
+ async fn send_clawbot_outbound_reaction(
+ &mut self,
+ reaction: ProviderOutboundReaction,
+ ) -> Result<()> {
+ #[cfg(test)]
+ {
+ self.clawbot_outbound_reactions.push(reaction);
+ Ok(())
+ }
+
+ #[cfg(not(test))]
+ {
+ let workspace_root = self
+ .clawbot_workspace_root
+ .clone()
+ .context("missing clawbot workspace root for outbound reaction")?;
+ let runtime = ClawbotRuntime::load(workspace_root)?;
+ let provider = runtime
+ .feishu_provider()
+ .context("missing Feishu config for clawbot outbound bridge")?;
+ provider.add_reaction(reaction).await
+ }
+ }
+
+ fn register_pending_clawbot_turn(
+ &mut self,
+ thread_id: ThreadId,
+ session: ProviderSessionRef,
+ turn_id: String,
+ message_id: String,
+ turn_mode: ClawbotTurnMode,
+ ) {
+ let pending_turn = PendingClawbotTurn {
+ thread_id: thread_id.to_string(),
+ turn_id,
+ session,
+ message_id,
+ turn_mode,
+ };
+ self.clawbot_pending_turns
+ .entry(thread_id)
+ .or_default()
+ .push_back(pending_turn.clone());
+ if let Err(err) = self
+ .clawbot_store()
+ .and_then(|store| store.upsert_pending_turn(pending_turn))
+ {
+ tracing::warn!(
+ thread_id = %thread_id,
+ error = %err,
+ "failed to persist clawbot pending turn"
+ );
+ }
+ }
+
+ fn take_pending_clawbot_turn(
+ &mut self,
+ thread_id: ThreadId,
+ turn_id: &str,
+ ) -> Result > {
+ let Some(queue) = self.clawbot_pending_turns.get_mut(&thread_id) else {
+ return Ok(None);
+ };
+ let pending = queue
+ .iter()
+ .position(|pending| pending.turn_id == turn_id)
+ .and_then(|index| queue.remove(index));
+ if queue.is_empty() {
+ self.clawbot_pending_turns.remove(&thread_id);
+ }
+ if pending.is_some() {
+ let _ = self.remove_pending_clawbot_turn(thread_id, turn_id)?;
+ }
+ Ok(pending)
+ }
+
+ fn remove_pending_clawbot_turn(
+ &mut self,
+ thread_id: ThreadId,
+ turn_id: &str,
+ ) -> Result > {
+ self.clawbot_store()?
+ .remove_pending_turn(&thread_id.to_string(), turn_id)
+ }
+
+ fn clawbot_turn_mode_for_turn(
+ &self,
+ thread_id: ThreadId,
+ turn_id: &str,
+ ) -> Option {
+ self.clawbot_pending_turns
+ .get(&thread_id)?
+ .iter()
+ .find(|pending| pending.turn_id == turn_id)
+ .map(|pending| pending.turn_mode)
+ }
+
+ pub(super) fn clawbot_auto_response_op_for_server_request(
+ &self,
+ thread_id: ThreadId,
+ request: &ServerRequest,
+ ) -> Option {
+ match request {
+ ServerRequest::ToolRequestUserInput { params, .. } => {
+ let turn_mode = self.clawbot_turn_mode_for_turn(thread_id, ¶ms.turn_id)?;
+ if !turn_mode.uses_noninteractive_prompt_handling() {
+ return None;
+ }
+ Some(AppCommand::user_input_answer(
+ params.turn_id.clone(),
+ RequestUserInputResponse {
+ answers: HashMap::new(),
+ },
+ ))
+ }
+ ServerRequest::PermissionsRequestApproval { params, .. } => {
+ let turn_mode = self.clawbot_turn_mode_for_turn(thread_id, ¶ms.turn_id)?;
+ if !turn_mode.uses_noninteractive_prompt_handling() {
+ return None;
+ }
+ Some(AppCommand::request_permissions_response(
+ params.item_id.clone(),
+ RequestPermissionsResponse {
+ permissions: Default::default(),
+ scope: PermissionGrantScope::Turn,
+ },
+ ))
+ }
+ _ => None,
+ }
+ }
+
+ pub(super) async fn maybe_auto_resolve_clawbot_server_request(
+ &mut self,
+ app_server: &AppServerSession,
+ thread_id: ThreadId,
+ request: &ServerRequest,
+ ) -> bool {
+ let Some(op) = self.clawbot_auto_response_op_for_server_request(thread_id, request) else {
+ return false;
+ };
+
+ match self
+ .try_resolve_app_server_request(app_server, thread_id, &op)
+ .await
+ {
+ Ok(true) => true,
+ Ok(false) => {
+ self.pending_app_server_requests
+ .note_server_request(request);
+ false
+ }
+ Err(err) => {
+ tracing::warn!(
+ thread_id = %thread_id,
+ error = %err,
+ "failed to auto-resolve clawbot server request"
+ );
+ self.pending_app_server_requests
+ .note_server_request(request);
+ false
+ }
+ }
+ }
+}
+
+fn clawbot_approval_policy(
+ existing_policy: AskForApproval,
+ turn_mode: ClawbotTurnMode,
+) -> AskForApproval {
+ if !turn_mode.uses_noninteractive_prompt_handling() {
+ return existing_policy;
+ }
+
+ AskForApproval::Granular(GranularApprovalConfig {
+ sandbox_approval: false,
+ rules: false,
+ skill_approval: false,
+ request_permissions: false,
+ mcp_elicitations: false,
+ })
+}
+
+fn clawbot_outbound_text_for_turn(turn: &codex_app_server_protocol::Turn) -> Option {
+ match turn.status {
+ codex_app_server_protocol::TurnStatus::Completed => turn.items.iter().find_map(|item| {
+ let codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } = item else {
+ return None;
+ };
+ let trimmed = text.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
+ }),
+ codex_app_server_protocol::TurnStatus::Failed => turn
+ .error
+ .as_ref()
+ .map(|error| feishu_failure_reply_text(&error.message)),
+ codex_app_server_protocol::TurnStatus::Interrupted => {
+ last_agent_message_for_turn(turn).or_else(|| Some("Request interrupted.".to_string()))
+ }
+ codex_app_server_protocol::TurnStatus::InProgress => None,
+ }
+}
+
+fn last_agent_message_for_turn(turn: &codex_app_server_protocol::Turn) -> Option {
+ turn.items.iter().rev().find_map(|item| {
+ let codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } = item else {
+ return None;
+ };
+ let trimmed = text.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
+ })
+}
+
+fn next_unread_message_for_session(
+ runtime: &ClawbotRuntime,
+ session: &ProviderSessionRef,
+) -> Option {
+ runtime
+ .store()
+ .load_unread_messages()
+ .ok()?
+ .into_iter()
+ .filter(|message| message.session_ref() == *session)
+ .min_by(|left, right| {
+ left.received_at
+ .cmp(&right.received_at)
+ .then(left.message_id.cmp(&right.message_id))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use codex_app_server_protocol::ThreadItem;
+ use codex_app_server_protocol::Turn;
+ use codex_app_server_protocol::TurnStatus;
+ use pretty_assertions::assert_eq;
+
+ use super::clawbot_outbound_text_for_turn;
+
+ #[test]
+ fn interrupted_turn_uses_last_agent_message_for_reply() {
+ let turn = Turn {
+ id: "turn-1".to_string(),
+ status: TurnStatus::Interrupted,
+ items: vec![
+ ThreadItem::AgentMessage {
+ id: "agent-1".to_string(),
+ text: "first reply".to_string(),
+ phase: None,
+ memory_citation: None,
+ },
+ ThreadItem::AgentMessage {
+ id: "agent-2".to_string(),
+ text: " ".to_string(),
+ phase: None,
+ memory_citation: None,
+ },
+ ThreadItem::AgentMessage {
+ id: "agent-3".to_string(),
+ text: "last reply".to_string(),
+ phase: None,
+ memory_citation: None,
+ },
+ ],
+ error: None,
+ };
+
+ assert_eq!(
+ clawbot_outbound_text_for_turn(&turn),
+ Some("last reply".to_string())
+ );
+ }
+}
diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs
new file mode 100644
index 000000000..eacbf2e19
--- /dev/null
+++ b/codex-rs/tui/src/app/clawbot_controls.rs
@@ -0,0 +1,919 @@
+use anyhow::Context;
+use anyhow::Result;
+use codex_clawbot::ClawbotRuntime;
+use codex_clawbot::ClawbotTurnMode;
+use codex_clawbot::ConnectionStatus;
+use codex_clawbot::FeishuConfig;
+use codex_clawbot::ForwardingDirection;
+use codex_clawbot::ForwardingState;
+use codex_clawbot::ProviderKind;
+use codex_clawbot::ProviderRuntimeState;
+use codex_clawbot::ProviderSession;
+use codex_clawbot::ProviderSessionRef;
+use codex_protocol::ThreadId;
+
+use super::App;
+use crate::app_event::AppEvent;
+use crate::app_event::ClawbotFeishuConfigField;
+use crate::app_event::ClawbotForwardingChannel;
+use crate::app_event_sender::AppEventSender;
+use crate::app_server_session::AppServerSession;
+use crate::bottom_pane::SelectionAction;
+use crate::bottom_pane::SelectionItem;
+use crate::bottom_pane::SelectionViewParams;
+use crate::bottom_pane::custom_prompt_view::CustomPromptView;
+use crate::bottom_pane::popup_consts::standard_popup_hint_line;
+
+const CLAWBOT_MANAGEMENT_VIEW_ID: &str = "clawbot-management";
+
+impl ClawbotFeishuConfigField {
+ fn title(self) -> &'static str {
+ match self {
+ Self::AppId => "Feishu App ID",
+ Self::AppSecret => "Feishu App Secret",
+ Self::VerificationToken => "Feishu Verification Token",
+ Self::EncryptKey => "Feishu Encrypt Key",
+ Self::BotOpenId => "Feishu Bot Open ID",
+ Self::BotUserId => "Feishu Bot User ID",
+ }
+ }
+
+ fn current_value(self, config: Option<&FeishuConfig>) -> Option {
+ let value = match self {
+ Self::AppId => config.map(|config| config.app_id.clone()),
+ Self::AppSecret => config.map(|config| config.app_secret.clone()),
+ Self::VerificationToken => config.and_then(|config| config.verification_token.clone()),
+ Self::EncryptKey => config.and_then(|config| config.encrypt_key.clone()),
+ Self::BotOpenId => config.and_then(|config| config.bot_open_id.clone()),
+ Self::BotUserId => config.and_then(|config| config.bot_user_id.clone()),
+ }?;
+ let trimmed = value.trim().to_string();
+ (!trimmed.is_empty()).then_some(trimmed)
+ }
+
+ fn description(self, config: Option<&FeishuConfig>) -> String {
+ let Some(value) = self.current_value(config) else {
+ return "Not set".to_string();
+ };
+ if self.is_secret() {
+ format!("Configured: {}", mask_secret(&value))
+ } else {
+ format!("Configured: {}", truncate_value(&value, /*max_chars*/ 28))
+ }
+ }
+
+ fn prompt_placeholder(self) -> &'static str {
+ match self {
+ Self::AppId => "Paste the Feishu app_id and press Enter",
+ Self::AppSecret => "Paste the Feishu app_secret and press Enter",
+ Self::VerificationToken => {
+ "Paste the verification token, or submit an empty value to clear it"
+ }
+ Self::EncryptKey => "Paste the encrypt key, or submit an empty value to clear it",
+ Self::BotOpenId => "Paste the bot open_id, or submit an empty value to clear it",
+ Self::BotUserId => "Paste the bot user_id, or submit an empty value to clear it",
+ }
+ }
+
+ fn prompt_context_label(self, config: Option<&FeishuConfig>) -> String {
+ match self.current_value(config) {
+ Some(value) if self.is_secret() => {
+ format!("Current: {}", mask_secret(&value))
+ }
+ Some(value) => {
+ format!("Current: {}", truncate_value(&value, /*max_chars*/ 40))
+ }
+ None => "Current: not set".to_string(),
+ }
+ }
+
+ fn is_secret(self) -> bool {
+ matches!(
+ self,
+ Self::AppSecret | Self::VerificationToken | Self::EncryptKey
+ )
+ }
+}
+
+impl ClawbotForwardingChannel {
+ fn title(self) -> &'static str {
+ match self {
+ Self::Inbound => "Inbound Forwarding",
+ Self::Outbound => "Outbound Forwarding",
+ }
+ }
+
+ fn description(self, enabled: bool) -> String {
+ let direction = match self {
+ Self::Inbound => "Feishu -> Codex",
+ Self::Outbound => "Codex -> Feishu",
+ };
+ let state = if enabled { "Enabled" } else { "Disabled" };
+ format!("{state}: {direction}")
+ }
+
+ fn selected_description(self, enabled: bool) -> String {
+ match (self, enabled) {
+ (Self::Inbound, true) => {
+ "Disable automatic delivery of unread Feishu messages into the bound thread."
+ .to_string()
+ }
+ (Self::Inbound, false) => {
+ "Re-enable automatic delivery of unread Feishu messages into the bound thread."
+ .to_string()
+ }
+ (Self::Outbound, true) => {
+ "Disable reply forwarding from Codex back to the bound Feishu session.".to_string()
+ }
+ (Self::Outbound, false) => {
+ "Re-enable reply forwarding from Codex back to the bound Feishu session."
+ .to_string()
+ }
+ }
+ }
+}
+
+impl App {
+ pub(crate) fn open_clawbot_management_popup(&mut self) {
+ let initial_selected_idx = self
+ .chat_widget
+ .selected_index_for_active_view(CLAWBOT_MANAGEMENT_VIEW_ID);
+ let params = self.clawbot_management_popup_params(initial_selected_idx);
+ if !self
+ .chat_widget
+ .replace_selection_view_if_active(CLAWBOT_MANAGEMENT_VIEW_ID, params)
+ {
+ self.chat_widget
+ .show_selection_view(self.clawbot_management_popup_params(initial_selected_idx));
+ }
+ }
+
+ pub(crate) fn open_clawbot_feishu_config_prompt(&mut self, field: ClawbotFeishuConfigField) {
+ let config = ClawbotRuntime::load(self.config.cwd.to_path_buf())
+ .ok()
+ .and_then(|runtime| runtime.snapshot().config.feishu.clone());
+ let tx = self.app_event_tx.clone();
+ let view = CustomPromptView::new(
+ field.title().to_string(),
+ field.prompt_placeholder().to_string(),
+ Some(field.prompt_context_label(config.as_ref())),
+ Box::new(move |value| {
+ tx.send(AppEvent::SaveClawbotFeishuConfigValue { field, value });
+ }),
+ );
+ self.chat_widget.show_view(Box::new(view));
+ }
+
+ pub(crate) fn open_clawbot_manual_bind_prompt(&mut self) {
+ let current_thread = self
+ .active_thread_id
+ .map(|thread_id| thread_id.to_string())
+ .unwrap_or_else(|| "No active thread".to_string());
+ let current_binding = self
+ .active_thread_id
+ .and_then(|thread_id| {
+ ClawbotRuntime::load(self.config.cwd.to_path_buf())
+ .ok()
+ .and_then(|runtime| {
+ runtime
+ .bound_session_for_thread(&thread_id.to_string())
+ .ok()
+ })
+ .flatten()
+ .map(|session| session.session_id)
+ })
+ .unwrap_or_else(|| "not bound".to_string());
+ let tx = self.app_event_tx.clone();
+ let view = CustomPromptView::new(
+ "Bind Feishu session to current thread".to_string(),
+ "Paste a Feishu session id and press Enter".to_string(),
+ Some(format!(
+ "Thread: {current_thread} · Current binding: {current_binding}"
+ )),
+ Box::new(move |session_id| {
+ tx.send(AppEvent::SaveClawbotManualBindSessionId { session_id });
+ }),
+ );
+ self.chat_widget.show_view(Box::new(view));
+ }
+
+ pub(crate) fn save_clawbot_feishu_config_value(
+ &mut self,
+ field: ClawbotFeishuConfigField,
+ value: String,
+ ) -> Result<()> {
+ let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?;
+ let mut config = runtime.snapshot().config.feishu.clone().unwrap_or_default();
+ let trimmed = value.trim().to_string();
+ match field {
+ ClawbotFeishuConfigField::AppId => {
+ config.app_id = trimmed;
+ }
+ ClawbotFeishuConfigField::AppSecret => {
+ config.app_secret = trimmed;
+ }
+ ClawbotFeishuConfigField::VerificationToken => {
+ config.verification_token = (!trimmed.is_empty()).then_some(trimmed);
+ }
+ ClawbotFeishuConfigField::EncryptKey => {
+ config.encrypt_key = (!trimmed.is_empty()).then_some(trimmed);
+ }
+ ClawbotFeishuConfigField::BotOpenId => {
+ config.bot_open_id = (!trimmed.is_empty()).then_some(trimmed);
+ }
+ ClawbotFeishuConfigField::BotUserId => {
+ config.bot_user_id = (!trimmed.is_empty()).then_some(trimmed);
+ }
+ }
+ runtime.update_feishu_config(Some(config))?;
+ self.refresh_clawbot_provider_runtime()?;
+ self.open_clawbot_management_popup();
+ self.chat_widget
+ .add_info_message(format!("Updated {}.", field.title()), /*hint*/ None);
+ Ok(())
+ }
+
+ pub(crate) fn save_clawbot_turn_mode(&mut self, mode: ClawbotTurnMode) -> Result<()> {
+ let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?;
+ runtime.update_turn_mode(mode)?;
+ self.open_clawbot_management_popup();
+ self.chat_widget.add_info_message(
+ format!(
+ "Clawbot turn mode set to {}.",
+ clawbot_turn_mode_label(mode)
+ ),
+ /*hint*/ None,
+ );
+ Ok(())
+ }
+
+ pub(crate) async fn bind_clawbot_session_to_current_thread(
+ &mut self,
+ app_server: &mut AppServerSession,
+ session_id: String,
+ ) -> Result<()> {
+ let thread_id = self
+ .active_thread_id
+ .context("no active thread available for Clawbot binding")?;
+ let trimmed = session_id.trim().to_string();
+ if trimmed.is_empty() {
+ return Err(anyhow::anyhow!("session id cannot be empty"));
+ }
+ let session = ProviderSessionRef::new(ProviderKind::Feishu, trimmed.clone());
+ let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?;
+ if runtime.snapshot().config.feishu.is_some() {
+ runtime.scan_feishu_sessions().await?;
+ if !runtime
+ .snapshot()
+ .sessions
+ .iter()
+ .any(|existing| existing.session_ref() == session)
+ {
+ return Err(anyhow::anyhow!(
+ "Feishu session {trimmed} is not visible to the current bot"
+ ));
+ }
+ }
+ runtime.connect_session_to_thread(&session, thread_id.to_string())?;
+ self.refresh_clawbot_provider_runtime()?;
+ self.dispatch_next_clawbot_message(app_server, &session)
+ .await?;
+ self.open_clawbot_management_popup();
+ self.chat_widget.add_info_message(
+ format!("Bound thread {thread_id} to Feishu session {trimmed}."),
+ /*hint*/ None,
+ );
+ Ok(())
+ }
+
+ pub(crate) fn clawbot_disconnect_current_thread(&mut self) -> Result<()> {
+ let thread_id = self
+ .active_thread_id
+ .context("no active thread available for Clawbot disconnect")?;
+ let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?;
+ let Some(session) = runtime.disconnect_thread(&thread_id.to_string())? else {
+ return Err(anyhow::anyhow!(
+ "current thread is not bound to a Clawbot session"
+ ));
+ };
+ self.open_clawbot_management_popup();
+ self.chat_widget.add_info_message(
+ format!(
+ "Disconnected Feishu session {} from thread {thread_id}.",
+ session.session_id
+ ),
+ /*hint*/ None,
+ );
+ Ok(())
+ }
+
+ pub(crate) fn clawbot_set_current_thread_forwarding(
+ &mut self,
+ channel: ClawbotForwardingChannel,
+ enabled: bool,
+ ) -> Result<()> {
+ let thread_id = self
+ .active_thread_id
+ .context("no active thread available for Clawbot forwarding")?;
+ let direction = match channel {
+ ClawbotForwardingChannel::Inbound => ForwardingDirection::Inbound,
+ ClawbotForwardingChannel::Outbound => ForwardingDirection::Outbound,
+ };
+ let state = if enabled {
+ ForwardingState::Enabled
+ } else {
+ ForwardingState::Disabled
+ };
+ let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?;
+ runtime
+ .set_forwarding_state_for_thread(&thread_id.to_string(), direction, state)?
+ .context("current thread is not bound to a Clawbot session")?;
+ self.open_clawbot_management_popup();
+ self.chat_widget
+ .add_info_message(channel.description(enabled), /*hint*/ None);
+ Ok(())
+ }
+
+ pub(crate) fn retry_clawbot_feishu_connection(&mut self) -> Result<()> {
+ let runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?;
+ let Some(config) = runtime.snapshot().config.feishu.as_ref() else {
+ return Err(anyhow::anyhow!("Feishu credentials are not configured"));
+ };
+ if !config.has_api_credentials() {
+ return Err(anyhow::anyhow!("Feishu app_id and app_secret are required"));
+ }
+ self.refresh_clawbot_provider_runtime()?;
+ self.open_clawbot_management_popup();
+ self.chat_widget.add_info_message(
+ "Restarted Feishu runtime bridge.".to_string(),
+ /*hint*/ None,
+ );
+ Ok(())
+ }
+
+ pub(crate) async fn scan_clawbot_feishu_sessions(&mut self) -> Result<()> {
+ let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?;
+ runtime.scan_feishu_sessions().await?;
+ let discovered = runtime
+ .snapshot()
+ .sessions
+ .iter()
+ .filter(|session| session.provider == ProviderKind::Feishu)
+ .count();
+ self.open_clawbot_management_popup();
+ self.chat_widget.add_info_message(
+ format!("Scanned Feishu sessions. {discovered} discovered."),
+ /*hint*/ None,
+ );
+ Ok(())
+ }
+
+ pub(crate) fn clear_clawbot_feishu_sessions(&mut self) -> Result<()> {
+ let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?;
+ let sessions_before = runtime
+ .snapshot()
+ .sessions
+ .iter()
+ .filter(|session| {
+ session.provider == ProviderKind::Feishu && session.bound_thread_id.is_none()
+ })
+ .count();
+ let unread_before = runtime
+ .store()
+ .load_unread_messages()?
+ .into_iter()
+ .filter(|message| message.provider == ProviderKind::Feishu)
+ .filter(|message| {
+ !runtime
+ .snapshot()
+ .bindings
+ .iter()
+ .any(|binding| binding.session_ref() == message.session_ref())
+ })
+ .count();
+ runtime.clear_unbound_feishu_sessions()?;
+ self.open_clawbot_management_popup();
+ self.chat_widget.add_info_message(
+ format!(
+ "Cleared {sessions_before} unbound Feishu sessions and {unread_before} cached unread messages."
+ ),
+ /*hint*/ None,
+ );
+ Ok(())
+ }
+
+ fn clawbot_management_popup_params(
+ &self,
+ initial_selected_idx: Option,
+ ) -> SelectionViewParams {
+ let (snapshot, clearable_unread_count) =
+ ClawbotRuntime::load(self.config.cwd.to_path_buf())
+ .map(|runtime| {
+ let snapshot = runtime.snapshot().clone();
+ let clearable_unread_count = runtime
+ .store()
+ .load_unread_messages()
+ .unwrap_or_default()
+ .into_iter()
+ .filter(|message| message.provider == ProviderKind::Feishu)
+ .filter(|message| {
+ !snapshot
+ .bindings
+ .iter()
+ .any(|binding| binding.session_ref() == message.session_ref())
+ })
+ .count();
+ (snapshot, clearable_unread_count)
+ })
+ .unwrap_or_default();
+ let feishu_config = snapshot.config.feishu.as_ref();
+ let provider_state = snapshot
+ .runtime
+ .iter()
+ .find(|state| state.provider == ProviderKind::Feishu)
+ .cloned()
+ .unwrap_or(ProviderRuntimeState::unconfigured(ProviderKind::Feishu));
+ let active_thread_id = self.active_thread_id.map(|thread_id| thread_id.to_string());
+ let current_binding = active_thread_id.as_deref().and_then(|thread_id| {
+ snapshot
+ .bindings
+ .iter()
+ .find(|binding| binding.thread_id == thread_id)
+ });
+ let turn_mode = snapshot.config.turn_mode;
+ let next_turn_mode = match turn_mode {
+ ClawbotTurnMode::Interactive => ClawbotTurnMode::NonInteractive,
+ ClawbotTurnMode::NonInteractive => ClawbotTurnMode::Interactive,
+ };
+ let mut feishu_sessions = snapshot
+ .sessions
+ .iter()
+ .filter(|session| session.provider == ProviderKind::Feishu)
+ .cloned()
+ .collect::>();
+ feishu_sessions.sort_by(|left, right| {
+ right
+ .bound_thread_id
+ .is_some()
+ .cmp(&left.bound_thread_id.is_some())
+ .then(session_title(left).cmp(&session_title(right)))
+ .then(left.session_id.cmp(&right.session_id))
+ });
+ let bound_session_count = feishu_sessions
+ .iter()
+ .filter(|session| session.bound_thread_id.is_some())
+ .count();
+ let unbound_session_count = feishu_sessions.len().saturating_sub(bound_session_count);
+ let mut items = vec![
+ SelectionItem {
+ name: "Turn Mode".to_string(),
+ description: Some(clawbot_turn_mode_summary(turn_mode)),
+ selected_description: Some(match turn_mode {
+ ClawbotTurnMode::Interactive => {
+ "Switch clawbot-originated turns into non-interactive mode so remote sessions do not block on prompts.".to_string()
+ }
+ ClawbotTurnMode::NonInteractive => {
+ "Restore normal interactive prompt handling for clawbot-originated turns.".to_string()
+ }
+ }),
+ actions: vec![Box::new(move |tx| {
+ tx.send(AppEvent::ClawbotSetTurnMode {
+ mode: next_turn_mode,
+ });
+ })],
+ dismiss_on_select: false,
+ ..Default::default()
+ },
+ SelectionItem {
+ name: "Feishu Sessions".to_string(),
+ description: Some(format!(
+ "{} total · {} bound · {} unbound",
+ feishu_sessions.len(),
+ bound_session_count,
+ unbound_session_count
+ )),
+ selected_description: Some(
+ "Scan discovered Feishu chats, inspect bound/unbound state, and clear stale unbound cache."
+ .to_string(),
+ ),
+ is_disabled: true,
+ ..Default::default()
+ },
+ SelectionItem {
+ name: "Scan Feishu Sessions".to_string(),
+ description: Some(
+ "Discover Feishu chats and bot groups using the persisted workspace credentials."
+ .to_string(),
+ ),
+ selected_description: Some(
+ "Refresh the discovered session list before binding or cleanup."
+ .to_string(),
+ ),
+ is_disabled: !feishu_config.is_some_and(FeishuConfig::has_api_credentials),
+ actions: vec![Box::new(|tx| tx.send(AppEvent::ScanClawbotFeishuSessions))],
+ dismiss_on_select: false,
+ ..Default::default()
+ },
+ SelectionItem {
+ name: "Clear Unbound Sessions".to_string(),
+ description: Some(format!(
+ "Remove {unbound_session_count} unbound sessions and {clearable_unread_count} cached unread messages."
+ )),
+ selected_description: Some(
+ "Keep active bindings intact while dropping stale discovered-session cache."
+ .to_string(),
+ ),
+ is_disabled: unbound_session_count == 0 && clearable_unread_count == 0,
+ actions: vec![Box::new(|tx| tx.send(AppEvent::ClearClawbotFeishuSessions))],
+ dismiss_on_select: false,
+ ..Default::default()
+ },
+ ];
+
+ if feishu_sessions.is_empty() {
+ items.push(SelectionItem {
+ name: "No Feishu sessions discovered".to_string(),
+ description: Some(
+ "Run Scan Feishu Sessions to refresh the workspace-local discovered session list."
+ .to_string(),
+ ),
+ selected_description: Some(
+ "Discovered sessions appear here with their current bound or unbound state."
+ .to_string(),
+ ),
+ is_disabled: true,
+ ..Default::default()
+ });
+ } else {
+ items.extend(feishu_sessions.iter().map(|session| {
+ clawbot_session_item(
+ session,
+ active_thread_id.as_deref(),
+ current_binding.map(|binding| binding.session_id.as_str()),
+ )
+ }));
+ }
+
+ items.extend([
+ clawbot_config_item(ClawbotFeishuConfigField::AppId, feishu_config),
+ clawbot_config_item(ClawbotFeishuConfigField::AppSecret, feishu_config),
+ clawbot_config_item(ClawbotFeishuConfigField::VerificationToken, feishu_config),
+ clawbot_config_item(ClawbotFeishuConfigField::EncryptKey, feishu_config),
+ clawbot_config_item(ClawbotFeishuConfigField::BotOpenId, feishu_config),
+ clawbot_config_item(ClawbotFeishuConfigField::BotUserId, feishu_config),
+ SelectionItem {
+ name: "Retry Feishu Connection".to_string(),
+ description: Some(connection_description(&provider_state)),
+ selected_description: Some(
+ "Restart the workspace-local Feishu websocket/runtime bridge.".to_string(),
+ ),
+ is_disabled: !feishu_config.is_some_and(FeishuConfig::has_api_credentials),
+ actions: vec![Box::new(|tx| {
+ tx.send(AppEvent::RetryClawbotFeishuConnection)
+ })],
+ dismiss_on_select: false,
+ ..Default::default()
+ },
+ ]);
+
+ let bind_description = match (&active_thread_id, current_binding) {
+ (Some(thread_id), Some(binding)) => {
+ format!("Thread {thread_id} -> {}", binding.session_id)
+ }
+ (Some(thread_id), None) => format!("Thread {thread_id} is not bound"),
+ (None, _) => "No active thread".to_string(),
+ };
+ items.push(SelectionItem {
+ name: "Bind Current Thread".to_string(),
+ description: Some(bind_description),
+ selected_description: Some(
+ "Manually bind the current Codex thread to a Feishu session id.".to_string(),
+ ),
+ is_disabled: active_thread_id.is_none(),
+ actions: vec![Box::new(|tx| {
+ tx.send(AppEvent::OpenClawbotManualBindPrompt)
+ })],
+ dismiss_on_select: false,
+ ..Default::default()
+ });
+
+ for channel in [
+ ClawbotForwardingChannel::Inbound,
+ ClawbotForwardingChannel::Outbound,
+ ] {
+ let enabled = current_binding.is_some_and(|binding| match channel {
+ ClawbotForwardingChannel::Inbound => binding.inbound_forwarding_enabled,
+ ClawbotForwardingChannel::Outbound => binding.outbound_forwarding_enabled,
+ });
+ items.push(SelectionItem {
+ name: channel.title().to_string(),
+ description: Some(channel.description(enabled)),
+ selected_description: Some(channel.selected_description(enabled)),
+ is_disabled: current_binding.is_none(),
+ actions: vec![Box::new(move |tx| {
+ tx.send(AppEvent::ClawbotSetCurrentThreadForwarding {
+ channel,
+ enabled: !enabled,
+ });
+ })],
+ dismiss_on_select: false,
+ ..Default::default()
+ });
+ }
+
+ items.push(SelectionItem {
+ name: "Disconnect Current Thread".to_string(),
+ description: Some(
+ current_binding
+ .map(|binding| format!("Unbind {}", binding.session_id))
+ .unwrap_or_else(|| "No binding for current thread".to_string()),
+ ),
+ selected_description: Some(
+ "Remove the current thread's Feishu binding without deleting cached session state."
+ .to_string(),
+ ),
+ is_disabled: current_binding.is_none(),
+ actions: vec![Box::new(|tx| {
+ tx.send(AppEvent::ClawbotDisconnectCurrentThread)
+ })],
+ dismiss_on_select: false,
+ ..Default::default()
+ });
+
+ SelectionViewParams {
+ view_id: Some(CLAWBOT_MANAGEMENT_VIEW_ID),
+ title: Some("Clawbot".to_string()),
+ subtitle: Some(
+ "Manage workspace-local Feishu credentials, sessions, and the current thread binding."
+ .to_string(),
+ ),
+ footer_hint: Some(standard_popup_hint_line()),
+ items,
+ initial_selected_idx,
+ ..Default::default()
+ }
+ }
+}
+
+fn clawbot_config_item(
+ field: ClawbotFeishuConfigField,
+ config: Option<&FeishuConfig>,
+) -> SelectionItem {
+ SelectionItem {
+ name: field.title().to_string(),
+ description: Some(field.description(config)),
+ selected_description: Some(
+ "Persist this workspace-local Feishu setting under .codex/clawbot/config.toml."
+ .to_string(),
+ ),
+ actions: vec![Box::new(move |tx| {
+ tx.send(AppEvent::OpenClawbotFeishuConfigPrompt { field });
+ })],
+ dismiss_on_select: false,
+ ..Default::default()
+ }
+}
+
+fn connection_description(state: &ProviderRuntimeState) -> String {
+ let status = match state.connection {
+ ConnectionStatus::Unconfigured => "Unconfigured",
+ ConnectionStatus::Disconnected => "Disconnected",
+ ConnectionStatus::Connecting => "Connecting",
+ ConnectionStatus::Connected => "Connected",
+ ConnectionStatus::Error => "Error",
+ };
+ match state.last_error.as_deref() {
+ Some(error) if !error.trim().is_empty() => {
+ format!("{status}: {}", truncate_value(error, /*max_chars*/ 48))
+ }
+ _ => status.to_string(),
+ }
+}
+
+fn clawbot_session_item(
+ session: &ProviderSession,
+ active_thread_id: Option<&str>,
+ current_binding_session_id: Option<&str>,
+) -> SelectionItem {
+ let is_current = current_binding_session_id.is_some_and(|session_id| {
+ session_id == session.session_id && session.provider == ProviderKind::Feishu
+ });
+ let description = if session.bound_thread_id.is_some() {
+ format!("bound · {} unread", session.unread_count)
+ } else {
+ format!("unbound · {} unread", session.unread_count)
+ };
+ let jump_target = session
+ .bound_thread_id
+ .as_deref()
+ .and_then(|thread_id| ThreadId::from_string(thread_id).ok());
+ let selected_description = match (active_thread_id, session.bound_thread_id.as_deref()) {
+ (Some(thread_id), Some(bound_thread_id)) if thread_id == bound_thread_id => {
+ format!(
+ "Current thread {thread_id} is already bound to Feishu session {}.",
+ session.session_id
+ )
+ }
+ (_, Some(bound_thread_id)) if jump_target.is_some() => {
+ format!(
+ "Jump to bound thread {bound_thread_id} to continue or manage Feishu session {}.",
+ session.session_id
+ )
+ }
+ (_, Some(bound_thread_id)) => format!(
+ "Feishu session {} is bound to invalid thread id {bound_thread_id}.",
+ session.session_id
+ ),
+ (Some(thread_id), None) if is_current => {
+ format!(
+ "Thread {thread_id} is already bound to Feishu session {}.",
+ session.session_id
+ )
+ }
+ (Some(thread_id), None) => format!(
+ "Bind current thread {thread_id} directly to Feishu session {}.",
+ session.session_id
+ ),
+ (None, None) => format!(
+ "Open a Codex thread before binding Feishu session {}.",
+ session.session_id
+ ),
+ };
+ let session_id = session.session_id.clone();
+ let actions: Vec = if let Some(thread_id) = jump_target {
+ vec![Box::new(move |tx: &AppEventSender| {
+ tx.send(AppEvent::SelectAgentThread(thread_id));
+ })]
+ } else {
+ vec![Box::new(move |tx: &AppEventSender| {
+ tx.send(AppEvent::SaveClawbotManualBindSessionId {
+ session_id: session_id.clone(),
+ });
+ })]
+ };
+ let is_disabled = match session.bound_thread_id.as_deref() {
+ Some(bound_thread_id) => active_thread_id == Some(bound_thread_id) || jump_target.is_none(),
+ None => active_thread_id.is_none() || is_current,
+ };
+ SelectionItem {
+ name: session_title(session),
+ description: Some(description),
+ selected_description: Some(selected_description),
+ is_disabled,
+ actions,
+ dismiss_on_select: false,
+ ..Default::default()
+ }
+}
+
+fn session_title(session: &ProviderSession) -> String {
+ session
+ .display_name
+ .clone()
+ .filter(|name| !name.trim().is_empty())
+ .unwrap_or_else(|| session.session_id.clone())
+}
+
+fn clawbot_turn_mode_label(mode: ClawbotTurnMode) -> &'static str {
+ match mode {
+ ClawbotTurnMode::Interactive => "interactive",
+ ClawbotTurnMode::NonInteractive => "non-interactive",
+ }
+}
+
+fn clawbot_turn_mode_summary(mode: ClawbotTurnMode) -> String {
+ match mode {
+ ClawbotTurnMode::Interactive => {
+ "interactive: clawbot turns may surface question and approval prompts.".to_string()
+ }
+ ClawbotTurnMode::NonInteractive => {
+ "non-interactive: clawbot turns auto-dismiss question and permission prompts."
+ .to_string()
+ }
+ }
+}
+
+fn mask_secret(value: &str) -> String {
+ let chars = value.chars().count();
+ if chars <= 4 {
+ return "*".repeat(chars.max(1));
+ }
+ let suffix: String = value
+ .chars()
+ .rev()
+ .take(4)
+ .collect::>()
+ .into_iter()
+ .rev()
+ .collect();
+ format!("{}{}", "*".repeat(chars.saturating_sub(4)), suffix)
+}
+
+fn truncate_value(value: &str, max_chars: usize) -> String {
+ let chars = value.chars().collect::>();
+ if chars.len() <= max_chars {
+ return value.to_string();
+ }
+ let prefix = chars
+ .into_iter()
+ .take(max_chars.saturating_sub(1))
+ .collect::();
+ format!("{prefix}…")
+}
+
+#[cfg(test)]
+mod tests {
+ use codex_clawbot::ProviderKind;
+ use codex_clawbot::ProviderSession;
+ use codex_protocol::ThreadId;
+ use insta::assert_snapshot;
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+ use ratatui::layout::Rect;
+ use tokio::sync::mpsc::unbounded_channel;
+
+ use super::clawbot_session_item;
+ use crate::app_event::AppEvent;
+ use crate::app_event_sender::AppEventSender;
+ use crate::bottom_pane::SelectionViewParams;
+ use crate::bottom_pane::list_selection_view::ListSelectionView;
+ use crate::render::renderable::Renderable;
+
+ fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
+ terminal
+ .draw(|frame| {
+ let area = Rect::new(0, 0, width, height);
+ view.render(area, frame.buffer_mut());
+ })
+ .expect("draw popup");
+ format!("{:?}", terminal.backend())
+ }
+
+ #[test]
+ fn bound_session_item_jumps_to_bound_thread() {
+ let item = clawbot_session_item(
+ &ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_bound".to_string(),
+ display_name: Some("tracker".to_string()),
+ unread_count: 2,
+ last_message_at: None,
+ status: codex_clawbot::SessionStatus::Bound,
+ bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()),
+ },
+ Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"),
+ /*current_binding_session_id*/ None,
+ );
+
+ assert!(!item.is_disabled);
+ let (tx_raw, mut rx) = unbounded_channel::();
+ let tx = AppEventSender::new(tx_raw);
+ (item.actions[0])(&tx);
+
+ assert!(
+ matches!(
+ rx.try_recv().expect("event"),
+ AppEvent::SelectAgentThread(thread_id)
+ if thread_id
+ == ThreadId::from_string("019d607a-cf72-72e1-a5b7-0dc17ad019ad")
+ .expect("thread id")
+ ),
+ "expected bound session item to jump to the bound thread"
+ );
+ }
+
+ #[test]
+ fn bound_session_jump_item_snapshot() {
+ let item = clawbot_session_item(
+ &ProviderSession {
+ provider: ProviderKind::Feishu,
+ session_id: "chat_bound".to_string(),
+ display_name: Some("tracker".to_string()),
+ unread_count: 2,
+ last_message_at: None,
+ status: codex_clawbot::SessionStatus::Bound,
+ bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()),
+ },
+ Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"),
+ /*current_binding_session_id*/ None,
+ );
+ let (tx_raw, _rx) = unbounded_channel::();
+ let tx = AppEventSender::new(tx_raw);
+ let view = ListSelectionView::new(
+ SelectionViewParams {
+ title: Some("Clawbot".to_string()),
+ subtitle: Some("Session Jump".to_string()),
+ items: vec![item],
+ initial_selected_idx: Some(0),
+ ..Default::default()
+ },
+ tx,
+ );
+
+ assert_snapshot!(
+ "bound_session_jump_item",
+ render_selection_popup(&view, /*width*/ 92, /*height*/ 14)
+ );
+ }
+}
diff --git a/codex-rs/tui/src/app/jump_navigation.rs b/codex-rs/tui/src/app/jump_navigation.rs
new file mode 100644
index 000000000..17e77d39e
--- /dev/null
+++ b/codex-rs/tui/src/app/jump_navigation.rs
@@ -0,0 +1,154 @@
+use std::sync::Arc;
+
+use ratatui::text::Line;
+
+use crate::history_cell::AgentMessageCell;
+use crate::history_cell::HistoryCell;
+use crate::history_cell::McpToolCallCell;
+use crate::history_cell::ReasoningSummaryCell;
+use crate::history_cell::UserHistoryCell;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum JumpTargetKind {
+ UserMessage,
+ AgentMessage,
+ Reasoning,
+ ToolCall,
+ Event,
+}
+
+impl JumpTargetKind {
+ fn title(self) -> &'static str {
+ match self {
+ Self::UserMessage => "User Message",
+ Self::AgentMessage => "Assistant Message",
+ Self::Reasoning => "Reasoning",
+ Self::ToolCall => "Tool Call",
+ Self::Event => "Event",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct JumpTarget {
+ pub(crate) cell_index: usize,
+ pub(crate) ordinal: usize,
+ pub(crate) kind: JumpTargetKind,
+ pub(crate) title: String,
+ pub(crate) preview: String,
+}
+
+impl JumpTarget {
+ fn new(cell_index: usize, ordinal: usize, kind: JumpTargetKind, preview: String) -> Self {
+ Self {
+ cell_index,
+ ordinal,
+ kind,
+ title: format!("{} {ordinal}", kind.title()),
+ preview,
+ }
+ }
+
+ pub(crate) fn search_value(&self) -> String {
+ format!("{} {}", self.title, self.preview)
+ }
+}
+
+pub(crate) fn build_jump_targets(cells: &[Arc]) -> Vec {
+ let mut targets = Vec::new();
+ let mut ordinal = 1usize;
+
+ for (cell_index, cell) in cells.iter().enumerate() {
+ let preview = preview_from_lines(cell.transcript_lines(u16::MAX));
+ if preview.is_empty() {
+ continue;
+ }
+
+ targets.push(JumpTarget::new(
+ cell_index,
+ ordinal,
+ classify_history_cell(cell.as_ref()),
+ preview,
+ ));
+ ordinal += 1;
+ }
+
+ targets
+}
+
+fn classify_history_cell(cell: &dyn HistoryCell) -> JumpTargetKind {
+ if cell.as_any().is::() {
+ JumpTargetKind::UserMessage
+ } else if cell.as_any().is::() {
+ JumpTargetKind::AgentMessage
+ } else if cell.as_any().is::() {
+ JumpTargetKind::Reasoning
+ } else if cell.as_any().is::() {
+ JumpTargetKind::ToolCall
+ } else {
+ JumpTargetKind::Event
+ }
+}
+
+fn preview_from_lines(lines: Vec>) -> String {
+ lines
+ .into_iter()
+ .map(line_to_plain_text)
+ .map(|line| {
+ line.trim()
+ .trim_start_matches(['•', '-', '>', '›'])
+ .trim()
+ .to_string()
+ })
+ .filter(|line| !line.is_empty())
+ .take(2)
+ .collect::>()
+ .join(" ")
+}
+
+fn line_to_plain_text(line: Line<'static>) -> String {
+ line.spans
+ .into_iter()
+ .map(|span| span.content.into_owned())
+ .collect::()
+}
+
+#[cfg(test)]
+mod tests {
+ use pretty_assertions::assert_eq;
+ use ratatui::text::Line;
+ use std::sync::Arc;
+
+ use super::JumpTargetKind;
+ use super::build_jump_targets;
+ use crate::history_cell::AgentMessageCell;
+ use crate::history_cell::PlainHistoryCell;
+ use crate::history_cell::UserHistoryCell;
+
+ #[test]
+ fn build_jump_targets_classifies_cells_and_skips_empty_entries() {
+ let cells = vec![
+ Arc::new(UserHistoryCell {
+ message: "first question".to_string(),
+ text_elements: Vec::new(),
+ local_image_paths: Vec::new(),
+ remote_image_urls: Vec::new(),
+ }) as Arc,
+ Arc::new(PlainHistoryCell::new(vec![Line::from(" ")])),
+ Arc::new(AgentMessageCell::new(
+ vec![Line::from("first answer")],
+ /*is_first_line*/ true,
+ )),
+ ];
+
+ let targets = build_jump_targets(&cells);
+
+ assert_eq!(targets.len(), 2);
+ assert_eq!(targets[0].kind, JumpTargetKind::UserMessage);
+ assert_eq!(targets[0].title, "User Message 1");
+ assert_eq!(targets[0].preview, "first question");
+ assert_eq!(targets[1].kind, JumpTargetKind::AgentMessage);
+ assert_eq!(targets[1].title, "Assistant Message 2");
+ assert_eq!(targets[1].preview, "first answer");
+ }
+}
diff --git a/codex-rs/tui/src/app/key_chord.rs b/codex-rs/tui/src/app/key_chord.rs
new file mode 100644
index 000000000..9704121df
--- /dev/null
+++ b/codex-rs/tui/src/app/key_chord.rs
@@ -0,0 +1,165 @@
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
+use crossterm::event::KeyEventKind;
+use crossterm::event::KeyModifiers;
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub(crate) enum KeyChordState {
+ #[default]
+ Idle,
+ AwaitingCtrlXSecondKey,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum KeyChordAction {
+ UndoLastUserMessage,
+ CopyLatestOutput,
+ RespawnCurrentSession,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum KeyChordResolution {
+ NoMatch,
+ AwaitingSecondKey,
+ Matched(KeyChordAction),
+ Cancelled,
+ Forward(KeyEvent),
+}
+
+impl KeyChordState {
+ pub(crate) fn clear(&mut self) {
+ *self = Self::Idle;
+ }
+
+ pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyChordResolution {
+ match self {
+ Self::Idle => handle_idle_key_event(self, key_event),
+ Self::AwaitingCtrlXSecondKey => handle_ctrl_x_second_key(self, key_event),
+ }
+ }
+}
+
+fn handle_idle_key_event(state: &mut KeyChordState, key_event: KeyEvent) -> KeyChordResolution {
+ if key_event.kind != KeyEventKind::Press {
+ return KeyChordResolution::NoMatch;
+ }
+
+ if key_event.code == KeyCode::Char('x') && key_event.modifiers == KeyModifiers::CONTROL {
+ *state = KeyChordState::AwaitingCtrlXSecondKey;
+ KeyChordResolution::AwaitingSecondKey
+ } else {
+ KeyChordResolution::NoMatch
+ }
+}
+
+fn handle_ctrl_x_second_key(state: &mut KeyChordState, key_event: KeyEvent) -> KeyChordResolution {
+ if key_event.kind != KeyEventKind::Press {
+ return KeyChordResolution::AwaitingSecondKey;
+ }
+
+ let resolution = match (key_event.code, key_event.modifiers) {
+ (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
+ KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage)
+ }
+ (KeyCode::Char('y'), KeyModifiers::CONTROL) => {
+ KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput)
+ }
+ (KeyCode::Char('r'), KeyModifiers::CONTROL) => {
+ KeyChordResolution::Matched(KeyChordAction::RespawnCurrentSession)
+ }
+ (KeyCode::Char('x'), KeyModifiers::CONTROL) => KeyChordResolution::AwaitingSecondKey,
+ (KeyCode::Esc, _) => KeyChordResolution::Cancelled,
+ _ => KeyChordResolution::Forward(key_event),
+ };
+
+ if !matches!(resolution, KeyChordResolution::AwaitingSecondKey) {
+ *state = KeyChordState::Idle;
+ }
+
+ resolution
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+
+ #[test]
+ fn ctrl_x_ctrl_u_matches_undo_last_user_message() {
+ let mut state = KeyChordState::default();
+
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
+ KeyChordResolution::AwaitingSecondKey
+ );
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
+ KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage)
+ );
+ assert_eq!(state, KeyChordState::Idle);
+ }
+
+ #[test]
+ fn ctrl_x_ctrl_y_matches_copy_latest_output() {
+ let mut state = KeyChordState::default();
+
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
+ KeyChordResolution::AwaitingSecondKey
+ );
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)),
+ KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput)
+ );
+ assert_eq!(state, KeyChordState::Idle);
+ }
+
+ #[test]
+ fn ctrl_x_ctrl_r_matches_respawn_current_session() {
+ let mut state = KeyChordState::default();
+
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
+ KeyChordResolution::AwaitingSecondKey
+ );
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)),
+ KeyChordResolution::Matched(KeyChordAction::RespawnCurrentSession)
+ );
+ assert_eq!(state, KeyChordState::Idle);
+ }
+
+ #[test]
+ fn ctrl_x_unknown_second_key_is_forwarded_and_clears_state() {
+ let mut state = KeyChordState::default();
+
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
+ KeyChordResolution::AwaitingSecondKey
+ );
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
+ KeyChordResolution::Forward(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))
+ );
+ assert_eq!(state, KeyChordState::Idle);
+ }
+
+ #[test]
+ fn ctrl_x_release_keeps_waiting_for_second_key() {
+ let mut state = KeyChordState::default();
+
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
+ KeyChordResolution::AwaitingSecondKey
+ );
+ assert_eq!(
+ state.handle_key_event(KeyEvent::new_with_kind(
+ KeyCode::Char('x'),
+ KeyModifiers::CONTROL,
+ KeyEventKind::Release,
+ )),
+ KeyChordResolution::AwaitingSecondKey
+ );
+ assert_eq!(state, KeyChordState::AwaitingCtrlXSecondKey);
+ }
+}
diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs
new file mode 100644
index 000000000..0fe63d813
--- /dev/null
+++ b/codex-rs/tui/src/app/profile_management.rs
@@ -0,0 +1,1058 @@
+use std::collections::HashSet;
+
+use toml::Value as TomlValue;
+
+use super::App;
+use super::PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT;
+use crate::app_event::AppEvent;
+use crate::app_event::RuntimeProfileTarget;
+use crate::app_server_session::AppServerSession;
+use crate::bottom_pane::SelectionItem;
+use crate::bottom_pane::SelectionViewParams;
+use crate::bottom_pane::popup_consts::standard_popup_hint_line;
+use crate::external_editor;
+use crate::history_cell;
+use crate::profile_router::DefaultProfileRouter;
+use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH;
+use crate::profile_router::ProfileFallbackAction;
+use crate::profile_router::ProfileRouteEntry;
+use crate::profile_router::ProfileRouterState;
+use crate::profile_router::ProfileRouterStore;
+use crate::tui;
+use codex_app_server_client::AppServerEvent;
+use codex_app_server_protocol::ServerNotification;
+use codex_core::config::Config;
+use codex_protocol::ThreadId;
+
+const PROFILE_MANAGEMENT_VIEW_ID: &str = "profile-management";
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct RoutedProfileSummary {
+ id: String,
+ provider_label: String,
+ model: Option,
+ base_url: Option,
+ route_position: Option