diff --git a/.codex/skills/clawbot-feishu-debug/SKILL.md b/.codex/skills/clawbot-feishu-debug/SKILL.md new file mode 100644 index 000000000..4b699282b --- /dev/null +++ b/.codex/skills/clawbot-feishu-debug/SKILL.md @@ -0,0 +1,32 @@ +--- +name: clawbot-feishu-debug +description: Use when debugging Codex clawbot and Feishu integration problems such as missing sessions, missing inbound messages, reaction failures, websocket health, session binding, or reply forwarding. +--- + +Debug clawbot in this order: + +1. Check workspace-local state under `.codex/clawbot/`: + - `config.toml` + - `runtime.json` + - `sessions.json` + - `bindings.json` + - `unread_messages.jsonl` + - `inbound_receipts.json` +2. Distinguish these cases clearly: + - REST send path works + - runtime says `connected` + - websocket inbound events are actually arriving + These are not the same thing. +3. Verify the bot identity and app credentials match the intended Feishu app. +4. For session issues, check whether the session is auto-discovered, manually bound, reachable by the current bot, and still visible through Feishu APIs. +5. For repeated messages, check dedupe state in `inbound_receipts.json`. +6. For reaction failures, use exact official Feishu `emoji_type` names only. + +Useful checks: + +- `runtime.json` proving `connected` is not enough; confirm new inbound state is landing in unread / receipt files. +- If a session is bound but no inbound message lands in local state, suspect websocket delivery before suspecting thread routing. +- If send fails with “Bot/User can NOT be out of the chat”, the bound `chat_id` is invalid for the current bot. + +Prefer concrete file evidence over speculation, and end with the smallest next verification command. + diff --git a/.codex/skills/codex-loop-debug/SKILL.md b/.codex/skills/codex-loop-debug/SKILL.md new file mode 100644 index 000000000..af32642e9 --- /dev/null +++ b/.codex/skills/codex-loop-debug/SKILL.md @@ -0,0 +1,31 @@ +--- +name: codex-loop-debug +description: Use when debugging Codex loop behavior such as before-turn or after-turn ordering, queue semantics, until_no_followup behavior, /stop handling, or stale running background loop UI in TUI. +--- + +Treat loop issues as scheduler problems first, UI problems second. + +Debug in this order: + +1. Confirm the active workspace and inspect: + - `.codex/loop_timers.json` + - `.codex/loop_trigger_queues.json` +2. Separate these failure classes: + - trigger configuration is empty or wrong + - scheduler queue / round semantics are wrong + - follow-up submission keeps the chain alive + - `/stop` is not interrupting the active loop task + - TUI background loop indicator is stale +3. For `after-turn`, reason in rounds: + - all handlers in trigger order + - each follow-up drained serially + - next round only after the current follow-up queue is done +4. For “it keeps running forever”, check whether the loop keeps generating follow-up user turns. If yes, `until_no_followup` will never stop. +5. For UI banner bugs, verify whether scheduler state is really empty before blaming `chatwidget`. + +Useful principles: + +- One thread should have one serial after-turn runner. +- Queue state is a better source of truth than a single boolean gate. +- If the user asks for fail-fast behavior, do not preserve legacy loop semantics just to smooth migration. + diff --git a/.codex/skills/deep-discovery/SKILL.md b/.codex/skills/deep-discovery/SKILL.md new file mode 100644 index 000000000..d03717bb6 --- /dev/null +++ b/.codex/skills/deep-discovery/SKILL.md @@ -0,0 +1,31 @@ +--- +name: deep-discovery +description: "Use when the user wants a design-first workflow: read local docs like AGENTS.md and proposal/design files, ask detailed follow-up questions, use question tool where helpful, and only produce proposal/design/todos after high-confidence understanding." +--- + +This skill is for design and discovery work before implementation. + +Default flow: + +1. Read local context first: + - `AGENTS.md` + - `README.md` + - `design.md` + - `proposal.md` + - other nearby docs the task explicitly references +2. Build understanding before proposing a solution. +3. Ask complete follow-up questions. When many answers are needed, prefer the `question` tool. +4. Keep asking until the user's real goal, constraints, and success criteria are clear enough to design against. +5. Then summarize using STAR: + - Situation + - Task + - Action + - Result +6. Use first-principles reasoning. If the requested path is not the best path, say so and explain why. + +Guardrails: + +- Do not rush into code when the user asked for proposal or design first. +- Do not assume the user already knows the best solution shape. +- If motivation or constraints are unclear, pause and ask instead of inventing certainty. +- Once the goal is clear, keep the proposal concrete: data model, state model, file touch points, and validation commands. diff --git a/.codex/skills/enhanced-release/SKILL.md b/.codex/skills/enhanced-release/SKILL.md new file mode 100644 index 000000000..45f67c2a4 --- /dev/null +++ b/.codex/skills/enhanced-release/SKILL.md @@ -0,0 +1,24 @@ +--- +name: enhanced-release +description: Use when the user asks to cut, push, or monitor a release tag on the enhanced Codex fork, especially when they mention enhanced-release, new tag, GitHub release workflow, or release run status. +--- + +For this fork, the standard release path is the `enhanced-release` GitHub workflow on `Piping/codex-enhanced`. + +Default workflow: + +1. Inspect `git status --short`, `git tag --sort=-version:refname`, and recent commits. +2. Determine the next patch version instead of reusing the current workspace version. +3. Update the workspace version before tagging. +4. Keep the release commit narrow. Do not pull in unrelated untracked files. +5. Create a release commit like `chore: release x.y.z`. +6. Create tag `vx.y.z`. +7. Push `main` and the tag to remote `enhanced`. +8. Query the `enhanced-release` workflow run and report the run id and status. + +Guardrails: + +- Do not assume `HEAD` already has the right version number. +- Do not include scratch files, local scripts, `.drawio` sources, or other unrelated untracked files unless the user explicitly asks. +- If the user asks for the workflow by name, make sure the response explicitly references `enhanced-release`. + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbd2df27c..ca26d3551 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,11 +36,12 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - # Use a rust-release version that includes all native binaries. - CODEX_VERSION=0.115.0 + CODEX_VERSION="$(grep -m1 '^version' codex-rs/Cargo.toml | sed -E 's/version *= *"([^"]+)".*/\1/')" OUTPUT_DIR="${RUNNER_TEMP}" python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ + --github-repo "$GITHUB_REPOSITORY" \ + --workflow-name ".github/workflows/enhanced-release.yml" \ --package codex \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" diff --git a/.github/workflows/enhanced-release.yml b/.github/workflows/enhanced-release.yml new file mode 100644 index 000000000..ca65d3609 --- /dev/null +++ b/.github/workflows/enhanced-release.yml @@ -0,0 +1,234 @@ +name: enhanced-release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + release_tag: + description: Existing tag to build and publish, for example v0.1.1 + required: true + 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 }} + 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" + + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ steps.meta.outputs.checkout_ref }} + + - uses: dtolnay/rust-toolchain@1.100 + + - name: Validate tag matches Cargo version + shell: bash + run: | + set -euo pipefail + cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml | sed -E 's/version *= *"([^"]+)".*/\1/')" + tag_ver="${{ steps.meta.outputs.release_version }}" + [[ "${tag_ver}" == "${cargo_ver}" ]] \ + || { echo "Tag version ${tag_ver} does not match Cargo.toml ${cargo_ver}."; exit 1; } + + build: + needs: prepare + name: build-${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + defaults: + run: + working-directory: codex-rs + env: + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_RELEASE_LTO: fat + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + archive_ext: tar.gz + archive_kind: tar + - runner: macos-15-intel + target: x86_64-apple-darwin + archive_ext: tar.gz + archive_kind: tar + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + archive_ext: tar.gz + archive_kind: tar + - runner: windows-2022 + target: x86_64-pc-windows-msvc + archive_ext: zip + archive_kind: zip + steps: + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - uses: dtolnay/rust-toolchain@1.100 + with: + targets: ${{ matrix.target }} + + - name: Install sccache + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + with: + tool: sccache + + - name: Enable sccache wrapper + shell: bash + run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + + - 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 + run: | + set -euo pipefail + cargo build --locked --release --target "${{ matrix.target }}" --bin codex --bin codex-responses-api-proxy + + - name: Package unix artifacts + if: ${{ matrix.archive_kind == 'tar' }} + shell: bash + run: | + set -euo pipefail + stage_dir="dist/${{ matrix.target }}" + archive_name="codex-enhanced-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.${{ matrix.archive_ext }}" + + mkdir -p "${stage_dir}" + cp "target/${{ matrix.target }}/release/codex" "${stage_dir}/codex" + cp "target/${{ matrix.target }}/release/codex-responses-api-proxy" "${stage_dir}/codex-responses-api-proxy" + + tar -C "${stage_dir}" -czf "${archive_name}" codex codex-responses-api-proxy + + - name: Package windows artifacts + if: ${{ matrix.archive_kind == 'zip' }} + shell: pwsh + run: | + $stageDir = "dist/${{ matrix.target }}" + $archiveName = "codex-enhanced-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.${{ matrix.archive_ext }}" + + New-Item -ItemType Directory -Force -Path $stageDir | Out-Null + Copy-Item "target/${{ matrix.target }}/release/codex.exe" "$stageDir/codex.exe" + Copy-Item "target/${{ matrix.target }}/release/codex-responses-api-proxy.exe" "$stageDir/codex-responses-api-proxy.exe" + + Compress-Archive -Path "$stageDir/codex.exe", "$stageDir/codex-responses-api-proxy.exe" -DestinationPath $archiveName -Force + + - name: Upload packaged artifact + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.target }} + path: codex-rs/codex-enhanced-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.${{ matrix.archive_ext }} + if-no-files-found: error + + release: + needs: + - prepare + - build + 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 packaged artifacts + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: release-artifacts + merge-multiple: true + + - name: Write release notes from tag commit message + shell: bash + run: | + set -euo pipefail + commit="$(git rev-parse "${{ needs.prepare.outputs.checkout_ref }}^{commit}")" + git log -1 --format=%B "${commit}" > release-notes.md + echo >> release-notes.md + + - name: Publish GitHub Release assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.prepare.outputs.release_tag }} + name: Codex Enhanced ${{ needs.prepare.outputs.release_version }} + body_path: release-notes.md + files: release-artifacts/* + fail_on_unmatched_files: true + prerelease: ${{ contains(needs.prepare.outputs.release_version, '-') }} diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml new file mode 100644 index 000000000..977194781 --- /dev/null +++ b/.github/workflows/pypi-release.yml @@ -0,0 +1,198 @@ +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 + +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 }} + 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" + + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ steps.meta.outputs.checkout_ref }} + + - name: Validate tag matches Cargo version + shell: bash + run: | + set -euo pipefail + cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml | sed -E 's/version *= *"([^"]+)".*/\1/')" + tag_ver="${{ steps.meta.outputs.release_version }}" + [[ "${tag_ver}" == "${cargo_ver}" ]] \ + || { echo "Tag version ${tag_ver} does not match Cargo.toml ${cargo_ver}."; exit 1; } + + build: + needs: prepare + name: build-wheel-${{ 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 + binary_name: codex + - runner: macos-15-intel + target: x86_64-apple-darwin + binary_name: codex + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + binary_name: codex + - runner: windows-2022 + target: 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: dtolnay/rust-toolchain@1.100 + with: + targets: ${{ matrix.target }} + + - 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 + + - 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 codex binary + shell: bash + working-directory: codex-rs + run: | + set -euo pipefail + cargo build --locked --release --target "${{ matrix.target }}" --bin codex + + - 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" \ + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/${{ 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 + - build + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Download wheel artifacts + uses: actions/download-artifact@v4 + with: + pattern: pypi-wheel-* + path: dist + merge-multiple: true + + - name: Publish codex-enhanced wheels to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true + verbose: true diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c203e2b74..19bc0b20b 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -67,7 +67,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 with: components: rustfmt - name: cargo fmt @@ -83,7 +83,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: cargo-shear @@ -98,7 +98,7 @@ jobs: if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' || github.event_name == 'push' }} steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -124,7 +124,7 @@ jobs: argument_comment_lint_prebuilt: name: Argument comment lint - ${{ matrix.name }} - runs-on: ${{ matrix.runs_on || matrix.runner }} + runs-on: ${{ fromJSON(github.repository_owner == 'openai' && matrix.runs_on || format('"{0}"', matrix.runner)) }} needs: changed if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} strategy: @@ -137,9 +137,7 @@ jobs: runner: macos-15-xlarge - name: Windows runner: windows-x64 - runs_on: - group: codex-runners - labels: codex-windows-x64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-x64"}' steps: - uses: actions/checkout@v6 - name: Install Linux sandbox build dependencies @@ -148,7 +146,7 @@ jobs: run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -191,39 +189,27 @@ jobs: - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl profile: dev - runs_on: - group: codex-runners - labels: codex-linux-x64 + runs_on: '{"group":"codex-runners","labels":"codex-linux-x64"}' - runner: ubuntu-24.04 target: x86_64-unknown-linux-gnu profile: dev - runs_on: - group: codex-runners - labels: codex-linux-x64 + runs_on: '{"group":"codex-runners","labels":"codex-linux-x64"}' - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl profile: dev - runs_on: - group: codex-runners - labels: codex-linux-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-linux-arm64"}' - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu profile: dev - runs_on: - group: codex-runners - labels: codex-linux-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-linux-arm64"}' - runner: windows-x64 target: x86_64-pc-windows-msvc profile: dev - runs_on: - group: codex-runners - labels: codex-windows-x64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-x64"}' - runner: windows-arm64 target: aarch64-pc-windows-msvc profile: dev - runs_on: - group: codex-runners - labels: codex-windows-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-arm64"}' # Also run representative release builds on Mac and Linux because # there could be release-only build errors we want to catch. @@ -235,27 +221,19 @@ jobs: - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl profile: release - runs_on: - group: codex-runners - labels: codex-linux-x64 + runs_on: '{"group":"codex-runners","labels":"codex-linux-x64"}' - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl profile: release - runs_on: - group: codex-runners - labels: codex-linux-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-linux-arm64"}' - runner: windows-x64 target: x86_64-pc-windows-msvc profile: release - runs_on: - group: codex-runners - labels: codex-windows-x64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-x64"}' - runner: windows-arm64 target: aarch64-pc-windows-msvc profile: release - runs_on: - group: codex-runners - labels: codex-windows-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-arm64"}' steps: - uses: actions/checkout@v6 @@ -272,7 +250,7 @@ jobs: fi sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" fi - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 with: targets: ${{ matrix.target }} components: clippy @@ -546,7 +524,7 @@ jobs: tests: name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} - runs-on: ${{ matrix.runs_on || matrix.runner }} + runs-on: ${{ fromJSON(github.repository_owner == 'openai' && matrix.runs_on || format('"{0}"', matrix.runner)) }} timeout-minutes: ${{ matrix.runner == 'windows-arm64' && 35 || 30 }} needs: changed if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} @@ -572,27 +550,19 @@ jobs: target: x86_64-unknown-linux-gnu profile: dev remote_env: "true" - runs_on: - group: codex-runners - labels: codex-linux-x64 + runs_on: '{"group":"codex-runners","labels":"codex-linux-x64"}' - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu profile: dev - runs_on: - group: codex-runners - labels: codex-linux-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-linux-arm64"}' - runner: windows-x64 target: x86_64-pc-windows-msvc profile: dev - runs_on: - group: codex-runners - labels: codex-windows-x64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-x64"}' - runner: windows-arm64 target: aarch64-pc-windows-msvc profile: dev - runs_on: - group: codex-runners - labels: codex-windows-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-arm64"}' steps: - uses: actions/checkout@v6 @@ -615,7 +585,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@v2 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index a0d12d6db..8340076a5 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -17,7 +17,7 @@ jobs: build: if: ${{ inputs.publish }} name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runs_on || matrix.runner }} + runs-on: ${{ fromJSON(github.repository_owner == 'openai' && matrix.runs_on || format('"{0}"', matrix.runner)) }} timeout-minutes: 60 strategy: @@ -48,14 +48,12 @@ jobs: lib_name: argument_comment_lint@nightly-2025-09-18-x86_64-pc-windows-msvc.dll runner_binary: argument-comment-lint.exe cargo_dylint_binary: cargo-dylint.exe - runs_on: - group: codex-runners - labels: codex-windows-x64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-x64"}' steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 with: toolchain: nightly-2025-09-18 targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index f762fbc4b..62e77190a 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -23,7 +23,7 @@ on: jobs: build-windows-binaries: name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on }} + runs-on: ${{ fromJSON(github.repository_owner == 'openai' && matrix.runs_on || format('"{0}"', matrix.runner)) }} timeout-minutes: 60 permissions: contents: read @@ -41,30 +41,22 @@ jobs: target: x86_64-pc-windows-msvc bundle: primary build_args: --bin codex --bin codex-responses-api-proxy - runs_on: - group: codex-runners - labels: codex-windows-x64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-x64"}' - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: primary build_args: --bin codex --bin codex-responses-api-proxy - runs_on: - group: codex-runners - labels: codex-windows-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-arm64"}' - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: helpers build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner - runs_on: - group: codex-runners - labels: codex-windows-x64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-x64"}' - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: helpers build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner - runs_on: - group: codex-runners - labels: codex-windows-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-arm64"}' steps: - uses: actions/checkout@v6 @@ -82,10 +74,17 @@ jobs: Write-Host "Total RAM: $ramGiB GiB" Write-Host "Disk usage:" Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}} - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 with: targets: ${{ matrix.target }} + - name: Install Windows build dependencies + shell: powershell + run: | + if (-not (Get-Command protoc -ErrorAction SilentlyContinue)) { + choco install protoc -y --no-progress + } + - name: Cargo build (Windows binaries) shell: bash run: | @@ -122,7 +121,7 @@ jobs: needs: - build-windows-binaries name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runs_on }} + runs-on: ${{ fromJSON(github.repository_owner == 'openai' && matrix.runs_on || format('"{0}"', matrix.runner)) }} timeout-minutes: 60 permissions: contents: read @@ -137,14 +136,10 @@ jobs: include: - runner: windows-x64 target: x86_64-pc-windows-msvc - runs_on: - group: codex-runners - labels: codex-windows-x64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-x64"}' - runner: windows-arm64 target: aarch64-pc-windows-msvc - runs_on: - group: codex-runners - labels: codex-windows-arm64 + runs_on: '{"group":"codex-runners","labels":"codex-windows-arm64"}' steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1ec9bd28b..1691fcceb 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.92 + - uses: dtolnay/rust-toolchain@1.100 - name: Validate tag matches Cargo.toml version shell: bash run: | @@ -57,9 +57,7 @@ jobs: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} + CARGO_PROFILE_RELEASE_LTO: fat strategy: fail-fast: false @@ -115,7 +113,16 @@ jobs: run: | set -euo pipefail sudo apt-get update -y - 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 + + - 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 UBSan runtime (musl) if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} shell: bash @@ -125,10 +132,21 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 with: targets: ${{ matrix.target }} + - 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" + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) shell: bash @@ -386,7 +404,7 @@ jobs: needs: tag-check uses: ./.github/workflows/rust-release-windows.yml with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} + release-lto: fat secrets: inherit argument-comment-lint-release-assets: diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index c5026fe8c..e82489ca0 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -7,10 +7,10 @@ on: jobs: sdks: - runs-on: - group: codex-runners - labels: codex-linux-x64 + runs-on: ${{ fromJSON(github.repository_owner == 'openai' && '{"group":"codex-runners","labels":"codex-linux-x64"}' || '"ubuntu-latest"') }} timeout-minutes: 10 + env: + RUSTC_WRAPPER: "" steps: - name: Checkout repository uses: actions/checkout@v6 @@ -20,7 +20,7 @@ jobs: run: | set -euo pipefail sudo apt-get update -y - 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 - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -33,7 +33,15 @@ jobs: node-version: 22 cache: pnpm - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100 + + - name: Clear workspace build rustflags for SDK builds + shell: bash + run: | + set -euo pipefail + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - name: build codex run: cargo build --bin codex diff --git a/.gitignore b/.gitignore index 8f39b7b1c..17d49db39 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,8 @@ CHANGELOG.ignore.md __pycache__/ *.pyc +# codex local runtime data +!.codex/ +.codex/* +!.codex/skills/ +!.codex/skills/** diff --git a/AGENTS.md b/AGENTS.md index 3a287a599..459acf2ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,11 @@ In the codex-rs folder where the rust code lives: +- This fork has a few project-specific overrides: + - During active development, if the user explicitly narrows validation to `cargo check` and `cargo build`, treat that as an override for the default test / clippy / lint flow in this file. Do not expand validation unless the user later asks for it. + - Prefer KISS. Solve the current concrete problem with the smallest clear design that works. + - When requirements change, fail fast. Remove superseded paths instead of preserving fallback compatibility, dual behavior, or migration shims unless the user explicitly asks for backward compatibility. + - Do not add speculative extension points for imagined future needs. Extract an abstraction only after the variation is already real or the extension boundary is clearly required. - Crate names are prefixed with `codex-`. For example, the `core` folder's crate is named `codex-core` - When using format! and you can inline variables into {}, always do that. - Install any commands the repo relies on (for example `just`, `rg`, or `cargo-insta`) if they aren't already available before running instructions here. diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ee89f6243..47c3151f0 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -733,6 +733,7 @@ "crc32fast_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", "crc_3.4.0": "{\"dependencies\":[{\"name\":\"crc-catalog\",\"req\":\"^2.4.0\"}],\"features\":{}}", "critical-section_1.2.0": "{\"dependencies\":[],\"features\":{\"restore-state-bool\":[],\"restore-state-none\":[],\"restore-state-u16\":[],\"restore-state-u32\":[],\"restore-state-u64\":[],\"restore-state-u8\":[],\"restore-state-usize\":[],\"std\":[\"restore-state-bool\"]}}", + "cron_0.16.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"clock\"],\"name\":\"chrono\",\"req\":\"~0.4\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"~0.6\"},{\"name\":\"once_cell\",\"req\":\"^1.10\"},{\"features\":[\"macros\"],\"name\":\"phf\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.10\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.164\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.164\"},{\"name\":\"winnow\",\"req\":\"^0.7.0\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "crossbeam-channel_0.5.15": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-utils/std\"]}}", "crossbeam-deque_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-epoch\",\"req\":\"^0.9.17\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-epoch/std\",\"crossbeam-utils/std\"]}}", "crossbeam-epoch_0.9.18": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"name\":\"loom-crate\",\"optional\":true,\"package\":\"loom\",\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"loom\":[\"loom-crate\",\"crossbeam-utils/loom\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}", @@ -979,6 +980,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\"]}}", "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\"]}}", "libdbus-sys_0.2.7": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.0.78\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"default\":[\"pkg-config\"],\"vendored\":[\"cc\"]}}", @@ -1076,6 +1078,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.1": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"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.1": "{\"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.1\"},{\"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.1": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"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.1": "{\"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.1\"},{\"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\"],\"benchmarks\":[\"performance\"],\"cache\":[\"token-management\"],\"default\":[\"token-management\",\"cache\",\"encryption\",\"oauth\"],\"dev\":[\"token-management\",\"cache\",\"oauth\",\"encryption\"],\"encryption\":[\"ring\",\"sha2\",\"hmac\",\"pbkdf2\"],\"monitoring\":[],\"oauth\":[\"reqwest\",\"url\"],\"performance\":[\"criterion\"],\"token-management\":[]}}", + "openlark-cardkit_0.15.0-rc.1": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"kind\":\"dev\",\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"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.1": "{\"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.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-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-hr\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-meeting\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-security\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"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\":[\"openlark-ai\"],\"auth\":[\"openlark-auth\"],\"cardkit\":[\"auth\",\"openlark-cardkit\"],\"client\":[],\"client-communication\":[\"client\",\"communication\"],\"client-docs\":[\"client\",\"docs\"],\"client-p0\":[\"client\",\"p0-services\"],\"client-security\":[\"client\",\"security\"],\"communication\":[\"auth\",\"openlark-communication\"],\"core-layer\":[\"communication\",\"docs\",\"security\"],\"default\":[\"auth\",\"communication\"],\"docs\":[\"auth\",\"openlark-docs\"],\"hr\":[\"openlark-hr\"],\"meeting\":[\"auth\",\"openlark-meeting\"],\"p0-services\":[\"communication\",\"docs\",\"security\"],\"security\":[\"auth\",\"openlark-security\"],\"websocket\":[\"tokio-tungstenite\",\"futures-util\",\"lark-websocket-protobuf\",\"url\",\"prost\",\"reqwest\",\"log\"]}}", + "openlark-communication_0.15.0-rc.1": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"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.1": "{\"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.1\"},{\"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.1": "{\"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.1\"},{\"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.1": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"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.1": "{\"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.1\"},{\"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.1": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"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.1": "{\"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.1\"},{\"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.1": "{\"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.1\"},{\"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.1": "{\"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.1": "{\"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.1\"},{\"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.1": "{\"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.1\"},{\"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.1": "{\"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.1\"},{\"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.1": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"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\":{}}", @@ -1108,6 +1130,9 @@ "percent-encoding_2.3.2": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "petgraph_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\"],\"default\":[\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"rayon\":[\"dep:rayon\",\"indexmap/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[],\"unstable\":[\"generate\"]}}", "petgraph_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"name\":\"dot-parser\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"dot-parser-macros\",\"optional\":true,\"req\":\"^0.5.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.5.7\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\",\"dot_parser\"],\"default\":[\"std\",\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"dot_parser\":[\"std\",\"dep:dot-parser\",\"dep:dot-parser-macros\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"quickcheck\":[\"std\",\"dep:quickcheck\",\"graphmap\",\"stable_graph\"],\"rayon\":[\"std\",\"dep:rayon\",\"indexmap/rayon\",\"hashbrown/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[\"serde?/alloc\"],\"std\":[\"indexmap/std\"],\"unstable\":[\"generate\"]}}", + "phf_0.11.3": "{\"dependencies\":[{\"name\":\"phf_macros\",\"optional\":true,\"req\":\"^0.11.3\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.11.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"macros\":[\"phf_macros\"],\"std\":[\"phf_shared/std\"],\"uncased\":[\"phf_shared/uncased\"],\"unicase\":[\"phf_macros?/unicase\",\"phf_shared/unicase\"]}}", + "phf_generator_0.11.3": "{\"dependencies\":[{\"name\":\"criterion\",\"optional\":true,\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{}}", + "phf_macros_0.11.3": "{\"dependencies\":[{\"name\":\"phf_generator\",\"req\":\"^0.11.1\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.11.2\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"unicase_\",\"optional\":true,\"package\":\"unicase\",\"req\":\"^2.4.0\"}],\"features\":{\"unicase\":[\"unicase_\",\"phf_shared/unicase\"]}}", "phf_shared_0.11.3": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.9\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "pin-project-internal_1.1.10": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\",\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "pin-project-lite_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", @@ -1139,7 +1164,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\":[]}}", @@ -1148,6 +1179,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\"]}}", @@ -1211,7 +1243,9 @@ "rusticata-macros_4.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7.0\"}],\"features\":{}}", "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\":[]}}", + "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\"]}}", @@ -1366,6 +1400,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\"]}}", @@ -1399,6 +1434,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..32f730009 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,166 @@ -

npm i -g @openai/codex
or brew install --cask codex

-

Codex CLI is a coding agent from OpenAI that runs locally on your computer. -

- Codex CLI splash -

-
-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 ---- +Codex Enhanced is an operator-first Codex distribution for people who want a +local coding agent with better session operations, loop-driven automation, and +workspace-backed Feishu clawbot workflows. -## Quickstart +It stays close to upstream Codex where practical, but focuses fork effort on +the places that matter in daily operations: account switching, visible control +surfaces, repeatable release milestones, and automation that can be managed +from inside the TUI instead of bolted on from the outside. -### Installing and running Codex CLI +![Codex Enhanced hero](./docs/assets/readme-hero.svg) -Install globally with your preferred package manager: +![Codex Enhanced splash](./.github/codex-cli-splash.png) -```shell -# Install using npm -npm install -g @openai/codex +## Why This Fork Exists + +Upstream Codex already provides a strong local coding agent. This fork is about +operability: + +- switching between multiple ChatGPT accounts should not require manual auth + file juggling +- loop automation should be visible, interruptible, and predictable from the + TUI +- external chat entry points should bind to real Codex threads instead of + living in a separate bot stack +- fork-owned behavior should move toward explicit extension boundaries instead + of spreading through core runtime code + +## What You Get + +| Area | What is implemented now | +| --- | --- | +| Managed accounts | Named account slots under `~/.codex/accounts`, login-time registration, stable aliases, and operator-facing switching | +| TUI control surface | `Ctrl-P` control panel for sessions, accounts, clawbot management, and current-session workflows | +| Loop runtime | Before-turn and after-turn loop runners with queued scheduling, per-loop progress, `/stop` cancellation, and info cells in the chat stream | +| Clawbot | Workspace-backed `codex-clawbot` runtime, Feishu session discovery, manual bind, cached unread flush, and final answer forwarding | +| Fork boundary | Dedicated fork-owned crates and a release flow that keeps the fork delta reviewable | + +## Operator Surface + +| Overview | Release train | +| --- | --- | +| ![Operator surface](./docs/assets/operator-surface.svg) | ![Recent release train](./docs/assets/release-train.svg) | + +## Recent Releases + +### `v0.1.11` + +- fixed the empty `after-turn` path so the TUI does not leave a stale + `Running background loop` banner behind +- hardened background loop state cleanup for the latest scheduler flow + +### `v0.1.10` + +- added the `codex-clawbot` crate for workspace-backed Feishu session bridging +- added clawbot control-panel flows for session list, manual bind, retry, scan, + clear, flush, and configuration +- made after-turn loop rounds responsive, surfaced per-loop queue progress, and + restored `/stop` cancellation for hidden loop runs + +### `v0.1.9` + +- shipped loop v2 runtime updates and aligned release artifacts around the new + scheduler flow +- refreshed TUI and `tui_app_server` snapshots for the newer account and status + surfaces +- aligned stop-cleanup and app-server widget behavior with the current TUI + +## Clawbot And Loop Workflow + +Recent releases moved this fork beyond simple account management: + +- Feishu sessions can be discovered, scanned, or manually bound to the current + Codex thread from `Ctrl-P -> Clawbot -> Sessions` +- unread Feishu messages can be cached before binding, flushed into the bound + thread in order, and tagged in the TUI as `Feishu message` +- loop-generated activity now shows explicit progress and emits `Loop agent + reply` info cells so operators can see where automation output came from +- bound threads can forward their final assistant answer back into the linked + Feishu session + +## Repository Layout + +- [codex-rs/ext](./codex-rs/ext) + Fork-owned extension crate for account pool state, auth snapshots, and future + plugin host compatibility. +- [codex-rs/clawbot](./codex-rs/clawbot) + Workspace-backed clawbot runtime for Feishu session persistence, binding, and + provider integration. +- [codex-rs/tui](./codex-rs/tui) + Fullscreen local TUI implementation. +- [codex-rs/tui_app_server](./codex-rs/tui_app_server) + App-server-backed TUI implementation that mirrors relevant UX changes. +- [docs/fork-extension-mvp.md](./docs/fork-extension-mvp.md) + Fork proposal, MVP design, and phased roadmap. + +## Build And Run + +Build the Rust CLI locally: + +```bash +cd codex-rs +cargo build -p codex-cli +./target/debug/codex ``` -```shell -# Install using Homebrew -brew install --cask codex +Install it into your shell path: + +```bash +cd codex-rs +cargo build --release -p codex-cli +sudo ln -sf "$(pwd)/target/release/codex" /usr/local/bin/codex +codex --help ``` -Then simply run `codex` to get started. +## Managed Account Quick Start + +Register multiple ChatGPT logins into the managed account pool: -
-You can also go to the latest GitHub Release and download the appropriate binary for your platform. +```bash +codex login --auth primary +codex login --auth backup +codex login status +``` -Each GitHub Release contains many executables, but in practice, you likely want one of these: +Start Codex, then use: -- 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` +- `Ctrl-P -> Sessions` to open the global session picker +- `Ctrl-P -> Accounts` to switch the active managed account +- `Ctrl-P -> Accounts -> Rename` to rename account aliases +- `Ctrl-P -> Clawbot -> Sessions` to manage Feishu sessions and bind one to the + current thread -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. +Managed account state is stored under: -
+```text +~/.codex/accounts/ +├── account-pool.json +└── / + └── auth.json +``` -### Using Codex with your ChatGPT plan +Workspace-backed clawbot state is stored under: -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). +```text +/.codex/clawbot/ +├── bindings.json +├── config.toml +├── inbound_receipts.json +├── runtime.json +├── sessions.json +└── unread_messages.jsonl +``` -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). +## Upstream Relationship -## Docs +This project is based on OpenAI Codex and keeps upstream history so changes can +be rebased and audited cleanly. The maintenance goal is to keep the fork-owned +delta small, explicit, and increasingly isolated behind `codex-ext`, +`codex-clawbot`, and other dedicated extension layers instead of broad runtime +patches. -- [**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). +This repository remains licensed under the [Apache-2.0 License](LICENSE). diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 58fbd370f..66cc12d85 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -169,13 +169,13 @@ def main() -> int: if not workflow_url: workflow_url = DEFAULT_WORKFLOW_URL - workflow_id = workflow_url.rstrip("/").split("/")[-1] + workflow_repo, workflow_id = _parse_workflow_url(workflow_url) print(f"Downloading native artifacts from workflow {workflow_id}...") with _gha_group(f"Download native artifacts from workflow {workflow_id}"): with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: artifacts_dir = Path(artifacts_dir_str) - _download_artifacts(workflow_id, artifacts_dir) + _download_artifacts(workflow_repo, workflow_id, artifacts_dir) install_binary_components( artifacts_dir, vendor_dir, @@ -259,7 +259,15 @@ def fetch_rg( return [results[target] for target in targets] -def _download_artifacts(workflow_id: str, dest_dir: Path) -> None: +def _parse_workflow_url(workflow_url: str) -> tuple[str, str]: + parsed = urlparse(workflow_url) + path_parts = [part for part in parsed.path.split("/") if part] + if len(path_parts) < 5 or path_parts[2] != "actions" or path_parts[3] != "runs": + raise ValueError(f"Unsupported workflow URL: {workflow_url}") + return f"{path_parts[0]}/{path_parts[1]}", path_parts[4] + + +def _download_artifacts(workflow_repo: str, workflow_id: str, dest_dir: Path) -> None: cmd = [ "gh", "run", @@ -267,7 +275,7 @@ def _download_artifacts(workflow_id: str, dest_dir: Path) -> None: "--dir", str(dest_dir), "--repo", - "openai/codex", + workflow_repo, workflow_id, ] subprocess.check_call(cmd) diff --git a/codex-rs/.cargo/config.toml b/codex-rs/.cargo/config.toml index 5d5eb8fd6..f5d347bd9 100644 --- a/codex-rs/.cargo/config.toml +++ b/codex-rs/.cargo/config.toml @@ -1,3 +1,16 @@ +[build] +jobs = 8 +rustc-wrapper = "sccache" +rustflags = ["-Z", "threads=8"] + +[env] +SCCACHE_CACHE_SIZE = "10G" + +[target.'cfg(target_os = "macos")'] +# Stable Cargo can't select a linker by profile, so use ld64.lld for macOS +# target builds, which includes local `cargo test` runs. +rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/opt/lld/bin/ld64.lld"] + [target.'cfg(all(windows, target_env = "msvc"))'] rustflags = ["-C", "link-arg=/STACK:8388608"] diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9fc695795..6c11a384f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -380,7 +380,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -402,7 +402,7 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "base64 0.22.1", @@ -850,7 +850,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -1384,9 +1384,22 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" +[[package]] +name = "codex-accounts" +version = "0.1.13" +dependencies = [ + "base64 0.22.1", + "codex-app-server-protocol", + "codex-core", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-analytics" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-git-utils", "codex-login", @@ -1402,7 +1415,7 @@ dependencies = [ [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.1.13" dependencies = [ "ansi-to-tui", "ratatui", @@ -1411,7 +1424,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "assert_matches", @@ -1431,17 +1444,17 @@ 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", ] [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "app_test_support", @@ -1492,7 +1505,7 @@ dependencies = [ "tempfile", "time", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "toml 0.9.11+spec-1.1.0", "tracing", @@ -1504,7 +1517,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -1517,7 +1530,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "toml 0.9.11+spec-1.1.0", "tracing", "url", @@ -1525,7 +1538,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "clap", @@ -1553,7 +1566,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "clap", @@ -1567,14 +1580,14 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "tungstenite", + "tungstenite 0.27.0", "url", "uuid", ] [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "assert_cmd", @@ -1590,7 +1603,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-apply-patch", @@ -1605,7 +1618,7 @@ dependencies = [ [[package]] name = "codex-artifacts" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-package-manager", "flate2", @@ -1626,7 +1639,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.1.13" dependencies = [ "async-trait", "pretty_assertions", @@ -1636,7 +1649,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-backend-openapi-models", @@ -1651,16 +1664,24 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.1.13" dependencies = [ "serde", "serde_json", "serde_with", ] +[[package]] +name = "codex-btw" +version = "0.1.13" +dependencies = [ + "pretty_assertions", + "serde", +] + [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "clap", @@ -1676,15 +1697,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-clawbot" +version = "0.1.13" +dependencies = [ + "anyhow", + "async-trait", + "openlark", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml 0.9.11+spec-1.1.0", +] + [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "assert_cmd", "assert_matches", + "base64 0.22.1", "clap", "clap_complete", + "codex-accounts", "codex-app-server", "codex-app-server-protocol", "codex-app-server-test-client", @@ -1695,6 +1733,7 @@ dependencies = [ "codex-core", "codex-exec", "codex-execpolicy", + "codex-ext", "codex-features", "codex-login", "codex-mcp-server", @@ -1728,7 +1767,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.0.0" +version = "0.1.13" dependencies = [ "async-trait", "bytes", @@ -1743,7 +1782,7 @@ dependencies = [ "rand 0.9.2", "reqwest", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -1758,7 +1797,7 @@ dependencies = [ [[package]] name = "codex-cloud-requirements" -version = "0.0.0" +version = "0.1.13" dependencies = [ "async-trait", "base64 0.22.1", @@ -1781,7 +1820,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -1812,7 +1851,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -1827,7 +1866,7 @@ dependencies = [ [[package]] name = "codex-code-mode" -version = "0.0.0" +version = "0.1.13" dependencies = [ "async-trait", "pretty_assertions", @@ -1841,7 +1880,7 @@ dependencies = [ [[package]] name = "codex-config" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -1865,7 +1904,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -1877,7 +1916,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "arc-swap", @@ -1909,6 +1948,8 @@ dependencies = [ "codex-hooks", "codex-instructions", "codex-login", + "codex-loop", + "codex-loop-runtime", "codex-network-proxy", "codex-otel", "codex-plugin", @@ -1977,7 +2018,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", @@ -1998,7 +2039,7 @@ dependencies = [ [[package]] name = "codex-core-skills" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-analytics", @@ -2027,7 +2068,7 @@ dependencies = [ [[package]] name = "codex-debug-client" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "clap", @@ -2039,7 +2080,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "assert_cmd", @@ -2081,7 +2122,7 @@ dependencies = [ [[package]] name = "codex-exec-server" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -2099,13 +2140,13 @@ dependencies = [ "test-case", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", ] [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "clap", @@ -2122,7 +2163,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.0.0" +version = "0.1.13" dependencies = [ "allocative", "anyhow", @@ -2142,16 +2183,32 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.1.13" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] +[[package]] +name = "codex-ext" +version = "0.1.13" +dependencies = [ + "base64 0.22.1", + "codex-accounts", + "codex-app-server-protocol", + "codex-btw", + "codex-core", + "codex-threadmessages", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-features" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-login", "codex-otel", @@ -2165,7 +2222,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-protocol", @@ -2177,7 +2234,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "clap", @@ -2193,7 +2250,7 @@ dependencies = [ [[package]] name = "codex-git-utils" -version = "0.0.0" +version = "0.1.13" dependencies = [ "assert_matches", "codex-utils-absolute-path", @@ -2212,7 +2269,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "chrono", @@ -2230,7 +2287,7 @@ dependencies = [ [[package]] name = "codex-instructions" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-protocol", "pretty_assertions", @@ -2239,7 +2296,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.1.13" dependencies = [ "keyring", "tracing", @@ -2247,7 +2304,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.1.13" dependencies = [ "cc", "clap", @@ -2269,7 +2326,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-core", "reqwest", @@ -2282,7 +2339,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -2318,9 +2375,37 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-loop" +version = "0.1.13" +dependencies = [ + "chrono", + "codex-utils-absolute-path", + "cron", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "codex-loop-runtime" +version = "0.1.13" +dependencies = [ + "chrono", + "codex-loop", + "codex-protocol", + "codex-utils-absolute-path", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "uuid", +] + [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-arg0", @@ -2349,7 +2434,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -2380,7 +2465,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.1.13" dependencies = [ "assert_matches", "async-stream", @@ -2398,7 +2483,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.1.13" dependencies = [ "chrono", "codex-api", @@ -2422,7 +2507,7 @@ dependencies = [ "strum_macros 0.28.0", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -2430,7 +2515,7 @@ dependencies = [ [[package]] name = "codex-package-manager" -version = "0.0.0" +version = "0.1.13" dependencies = [ "fd-lock", "flate2", @@ -2450,7 +2535,7 @@ dependencies = [ [[package]] name = "codex-plugin" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-utils-absolute-path", "codex-utils-plugins", @@ -2459,7 +2544,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.1.13" dependencies = [ "libc", "pretty_assertions", @@ -2467,7 +2552,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-execpolicy", @@ -2495,7 +2580,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "clap", @@ -2511,7 +2596,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "axum", @@ -2545,7 +2630,7 @@ dependencies = [ [[package]] name = "codex-rollout" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -2570,7 +2655,7 @@ dependencies = [ [[package]] name = "codex-sandboxing" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-network-proxy", "codex-protocol", @@ -2587,7 +2672,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.0.0" +version = "0.1.13" dependencies = [ "age", "anyhow", @@ -2608,7 +2693,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "base64 0.22.1", @@ -2628,7 +2713,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -2649,7 +2734,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -2658,7 +2743,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "chrono", @@ -2681,7 +2766,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-utils-cargo-bin", @@ -2692,7 +2777,7 @@ dependencies = [ [[package]] name = "codex-terminal-detection" -version = "0.0.0" +version = "0.1.13" dependencies = [ "pretty_assertions", "tracing", @@ -2700,16 +2785,24 @@ dependencies = [ [[package]] name = "codex-test-macros" -version = "0.0.0" +version = "0.1.13" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] +[[package]] +name = "codex-threadmessages" +version = "0.1.13" +dependencies = [ + "pretty_assertions", + "serde", +] + [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "arboard", @@ -2717,27 +2810,34 @@ dependencies = [ "base64 0.22.1", "chrono", "clap", + "codex-accounts", "codex-ansi-escape", "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", + "codex-btw", "codex-chatgpt", + "codex-clawbot", "codex-cli", "codex-client", "codex-cloud-requirements", "codex-core", "codex-exec-server", + "codex-ext", "codex-features", "codex-feedback", "codex-file-search", "codex-git-utils", "codex-login", + "codex-loop", + "codex-loop-runtime", "codex-otel", "codex-protocol", "codex-shell-command", "codex-state", "codex-terminal-detection", + "codex-threadmessages", "codex-tui-app-server", "codex-utils-absolute-path", "codex-utils-approval-presets", @@ -2753,6 +2853,7 @@ dependencies = [ "codex-windows-sandbox", "color-eyre", "cpal", + "cron", "crossterm", "derive_more 2.1.1", "diffy", @@ -2805,7 +2906,7 @@ dependencies = [ [[package]] name = "codex-tui-app-server" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "arboard", @@ -2813,25 +2914,31 @@ dependencies = [ "base64 0.22.1", "chrono", "clap", + "codex-accounts", "codex-ansi-escape", "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", + "codex-backend-client", + "codex-btw", "codex-chatgpt", "codex-cli", "codex-client", "codex-cloud-requirements", "codex-core", + "codex-ext", "codex-features", "codex-feedback", "codex-file-search", "codex-git-utils", "codex-login", + "codex-loop", "codex-otel", "codex-protocol", "codex-shell-command", "codex-state", "codex-terminal-detection", + "codex-threadmessages", "codex-utils-absolute-path", "codex-utils-approval-presets", "codex-utils-cargo-bin", @@ -2898,7 +3005,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.1.13" dependencies = [ "dirs", "path-absolutize", @@ -2912,14 +3019,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.1.13" dependencies = [ "lru 0.16.3", "sha1", @@ -2928,7 +3035,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.1.13" dependencies = [ "assert_cmd", "runfiles", @@ -2937,7 +3044,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.0.0" +version = "0.1.13" dependencies = [ "clap", "codex-protocol", @@ -2948,15 +3055,15 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.0.0" +version = "0.1.13" [[package]] name = "codex-utils-fuzzy-match" -version = "0.0.0" +version = "0.1.13" [[package]] name = "codex-utils-home-dir" -version = "0.0.0" +version = "0.1.13" dependencies = [ "dirs", "pretty_assertions", @@ -2965,7 +3072,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.1.13" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -2977,7 +3084,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.1.13" dependencies = [ "pretty_assertions", "serde_json", @@ -2986,7 +3093,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-core", "codex-lmstudio", @@ -2995,7 +3102,7 @@ dependencies = [ [[package]] name = "codex-utils-output-truncation" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-protocol", "codex-utils-string", @@ -3004,7 +3111,7 @@ dependencies = [ [[package]] name = "codex-utils-path" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-utils-absolute-path", "dunce", @@ -3014,7 +3121,7 @@ dependencies = [ [[package]] name = "codex-utils-plugins" -version = "0.0.0" +version = "0.1.13" dependencies = [ "serde", "serde_json", @@ -3023,7 +3130,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "filedescriptor", @@ -3039,7 +3146,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.1.13" dependencies = [ "assert_matches", "async-trait", @@ -3050,14 +3157,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.1.13" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.0.0" +version = "0.1.13" dependencies = [ "codex-core", "codex-protocol", @@ -3067,7 +3174,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.0.0" +version = "0.1.13" dependencies = [ "core-foundation 0.9.4", "libc", @@ -3077,14 +3184,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.0.0" +version = "0.1.13" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.1.13" dependencies = [ "pretty_assertions", "regex-lite", @@ -3092,14 +3199,14 @@ dependencies = [ [[package]] name = "codex-utils-template" -version = "0.0.0" +version = "0.1.13" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-v8-poc" -version = "0.0.0" +version = "0.1.13" dependencies = [ "pretty_assertions", "v8", @@ -3107,7 +3214,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "base64 0.22.1", @@ -3321,7 +3428,7 @@ dependencies = [ [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "assert_cmd", @@ -3345,7 +3452,7 @@ dependencies = [ "shlex", "tempfile", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -3436,6 +3543,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "cron" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97" +dependencies = [ + "chrono", + "once_cell", + "phf", + "winnow", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -3984,7 +4103,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4229,7 +4348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5113,7 +5232,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -5674,7 +5793,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5895,6 +6014,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" @@ -6158,7 +6288,7 @@ checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.1.13" dependencies = [ "anyhow", "codex-core", @@ -6452,7 +6582,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6899,54 +7029,453 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "openssl" -version = "0.10.75" +name = "openlark" +version = "0.15.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "fe508c03b5517af1605c03235638f9be5736d89c2c921c73b5e0c24c97014541" dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", + "chrono", + "openlark-ai", + "openlark-analytics", + "openlark-application", + "openlark-auth", + "openlark-cardkit", + "openlark-client", + "openlark-communication", + "openlark-core", + "openlark-docs", + "openlark-helpdesk", + "openlark-hr", + "openlark-mail", + "openlark-meeting", + "openlark-platform", + "openlark-protocol", + "openlark-security", + "openlark-user", + "openlark-webhook", + "openlark-workflow", + "serde", + "serde_json", + "serde_repr", ] [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "openlark-ai" +version = "0.15.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "a2ef35f179540072bbd2a02aa3544cff96d5778855d3afad1c65fc8033b51dbf" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "anyhow", + "async-trait", + "openlark-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", ] [[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-probe" -version = "0.2.1" +name = "openlark-analytics" +version = "0.15.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "28a78e778d66d90aa095e79a5db95531dd64b5965bdedd7fc51bc35aa8011bce" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "log", + "once_cell", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] [[package]] -name = "openssl-src" -version = "300.5.5+3.5.5" +name = "openlark-application" +version = "0.15.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +checksum = "a86364afdd947e5ace28671c88001d08120297ceb398d5a14e7dd35ee298c888" dependencies = [ - "cc", + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", ] [[package]] -name = "openssl-sys" +name = "openlark-auth" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1eede42dc5d35250b179399ce58e67a324a792b8f1ace20eabb35e99ff1d097" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "hex", + "hmac", + "openlark-core", + "pbkdf2", + "rand 0.8.5", + "regex", + "reqwest", + "ring", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-cardkit" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841785e4863a2215703ff02016314aec7b5fb1f743db567bf901f66bb766023c" +dependencies = [ + "openlark-core", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "openlark-client" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9e7edbe53e8bf26941a2ea645d4e888cc3883839f0508c77b98a61e8c9b2f7" +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-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffdd6c935c748ec5dabc0991a0414eb11d62a75b593b20c7be791d473a8568d" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "openlark-core" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054315773d07ab248f4744747ad4dc9193cb16faa6f96c2ce5998f8f7a7a7725" +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 = "openlark-docs" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a78a86061a0919140cf036345ea8d885f72cbce6fef363ef9a231a6e69031a" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "futures-util", + "log", + "once_cell", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-helpdesk" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff66d9b1474d0e4bdfa8fb0f65103e6eab63777a981f45cb1148806c13fdc08b" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "openlark-hr" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca9f1610210a0beab4e7c82e735e2b1c8357a3ccc985e83aec36373f93ab03ef" +dependencies = [ + "anyhow", + "async-trait", + "log", + "openlark-core", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "openlark-mail" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d13227c79031ebb5fccbacd7291c4130259955185f142a41b840a8e6ca7236" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "openlark-meeting" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d855705ea24d7221b4fccb98b6dd4bcf75567cfccbb38e463d881e36f36414e8" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "openlark-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "openlark-platform" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c02a1c0b0bf7386e61aafe1c534536f3ec9bbbd9ed477d686421248c446e76a1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "log", + "once_cell", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-protocol" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce999beb07b5c710564dc06b1483adec8afd767b02da775fff120fdaf010f489" +dependencies = [ + "bytes", + "prost 0.13.5", + "prost-build", +] + +[[package]] +name = "openlark-security" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d1a0eba2b41cfdd12d3d21298c74603baeeb3b6852303fd375b0b47958b77" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "hmac", + "log", + "openlark-core", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-user" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128243c92b485beeb5adf7801dbf22788e90b9cac141e0d280c3fdde3bbcbd98" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "log", + "once_cell", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "openlark-webhook" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c996281cadb1d5acfc868b7dde4ccc1680afdd75b0d4c64b9c6aeea689854207" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "openlark-workflow" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8573cba2aa1a1a363473c2cde620253d4c531f38ed3e6f6798228a698abd1a8" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" @@ -7008,7 +7537,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "reqwest", "serde_json", "thiserror 2.0.18", @@ -7027,7 +7556,7 @@ dependencies = [ "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "serde", "serde_json", "tonic", @@ -7096,7 +7625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -7230,6 +7759,39 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -7547,6 +8109,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" @@ -7554,7 +8136,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]] @@ -7570,6 +8199,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" @@ -7629,6 +8267,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" @@ -7979,7 +8629,7 @@ dependencies = [ "rama-utils", "rcgen", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -8290,7 +8940,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -8518,7 +9168,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8537,6 +9187,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" @@ -8549,6 +9212,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" @@ -9963,7 +10635,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10349,6 +11021,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" @@ -10357,11 +11045,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]] @@ -10469,7 +11157,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "sync_wrapper", "tokio", "tokio-rustls", @@ -10487,7 +11175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -10745,6 +11433,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" @@ -11409,7 +12117,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f19fb650f..2bc3f3791 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "analytics", + "accounts", "backend-client", "ansi-escape", "async-utils", @@ -19,6 +20,7 @@ members = [ "cloud-tasks", "cloud-tasks-client", "cli", + "clawbot", "connectors", "config", "shell-command", @@ -30,6 +32,7 @@ members = [ "instructions", "secrets", "exec", + "ext", "exec-server", "execpolicy", "execpolicy-legacy", @@ -84,11 +87,15 @@ members = [ "package-manager", "plugin", "artifacts", + "btw", + "loop", + "loop-runtime", + "threadmessages", ] resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.1.13" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 @@ -99,10 +106,12 @@ license = "Apache-2.0" [workspace.dependencies] # Internal app_test_support = { path = "app-server/tests/common" } +codex-accounts = { path = "accounts" } codex-ansi-escape = { path = "ansi-escape" } codex-analytics = { path = "analytics" } codex-api = { path = "codex-api" } codex-artifacts = { path = "artifacts" } +codex-btw = { path = "btw" } codex-code-mode = { path = "code-mode" } codex-package-manager = { path = "package-manager" } codex-app-server = { path = "app-server" } @@ -115,6 +124,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-cloud-requirements = { path = "cloud-requirements" } codex-connectors = { path = "connectors" } @@ -122,6 +132,7 @@ codex-config = { path = "config" } codex-core = { path = "core" } codex-core-skills = { path = "core-skills" } codex-exec = { path = "exec" } +codex-ext = { path = "ext" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -135,6 +146,8 @@ codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } +codex-loop = { path = "loop" } +codex-loop-runtime = { path = "loop-runtime" } codex-mcp-server = { path = "mcp-server" } codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } @@ -154,6 +167,7 @@ codex-state = { path = "state" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-test-macros = { path = "test-macros" } codex-terminal-detection = { path = "terminal-detection" } +codex-threadmessages = { path = "threadmessages" } codex-tui = { path = "tui" } codex-tui-app-server = { path = "tui_app_server" } codex-v8-poc = { path = "v8-poc" } @@ -402,15 +416,41 @@ ignored = [ "codex-v8-poc", ] +[profile.dev] +# CI disables incremental compilation for better cache reuse, but local +# edit-build-test loops are much faster when dev builds can reuse artifacts. +debug = 0 +incremental = false +lto = false +codegen-units = 12 + +[profile.dev.package."*"] +incremental = false + +[profile.dev.build-override] +debug = 0 +incremental = false + +[profile.test] +debug = 0 +incremental = false + +[profile.test.package."*"] +incremental = false + +[profile.test.build-override] +debug = 0 +incremental = false + [profile.release] -lto = "fat" +lto = false split-debuginfo = "off" # Because we bundle some of these executables with the TypeScript CLI, we # remove everything to make the binary as small as possible. strip = "symbols" -# See https://github.com/openai/codex/issues/1411 for details. -codegen-units = 1 +# Match local parallelism better to reduce release build time. +codegen-units = 12 [profile.ci-test] debug = 1 # Reduce debug symbol size diff --git a/codex-rs/README.md b/codex-rs/README.md index ad8a0506f..a9c030843 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -17,6 +17,7 @@ You can also install via Homebrew (`brew install --cask codex`) or download a pl - First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management). - Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md). +- Looking for Rust-workspace design notes? See [`docs/codex_mcp_interface.md`](./docs/codex_mcp_interface.md), [`docs/codex_tool_design_principles.md`](./docs/codex_tool_design_principles.md), and the component evolution diagram in [`docs/codex_component_evolution.drawio`](./docs/codex_component_evolution.drawio). ## What's new in the Rust CLI @@ -100,3 +101,21 @@ This folder is the root of a Cargo workspace. It contains quite a bit of experim - [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands. If you want to contribute or inspect behavior in detail, start by reading the module-level `README.md` files under each crate and run the project workspace from the top-level `codex-rs` directory so shared config, features, and build scripts stay aligned. + +## Faster Local Rust Iteration + +The workspace is tuned for faster local edit-build-test loops: + +- `profile.dev` and `profile.test` keep incremental compilation enabled. +- Local dev/test builds drop debug info to reduce compile and link time. +- The workspace forces `sccache` as the default Rust compiler wrapper. +- The recommended inner loop is `cargo check` plus a debug `cargo build`; tests, + clippy, and other heavier checks are deferred to later validation. + +Install `sccache` before running direct Cargo commands in this workspace: + +```shell +cargo check -p codex-cli +cargo build -p codex-cli +sccache --show-stats +``` diff --git a/codex-rs/accounts/Cargo.toml b/codex-rs/accounts/Cargo.toml new file mode 100644 index 000000000..4faf3c8a5 --- /dev/null +++ b/codex-rs/accounts/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "codex-accounts" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_accounts" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-core = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +base64 = { workspace = true } +codex-app-server-protocol = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/accounts/src/account_pool.rs b/codex-rs/accounts/src/account_pool.rs new file mode 100644 index 000000000..ee666c9b7 --- /dev/null +++ b/codex-rs/accounts/src/account_pool.rs @@ -0,0 +1,539 @@ +use serde::Deserialize; +use serde::Serialize; +use std::fs; +use std::io; +use std::path::PathBuf; + +use crate::account_signal::AccountLimitSignal; +use crate::account_signal::AccountRateLimitSnapshot; + +pub const ACCOUNT_POOL_STATE_RELATIVE_PATH: &str = "accounts/account-pool.json"; +const ACCOUNT_POOL_STATE_VERSION: u32 = 2; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountUsageWindowKind { + FiveHour, + Weekly, + Custom, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageEstimateSource { + Manual, + ResponseErrorInference, + LocalHeuristic, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountUsageWindow { + pub kind: AccountUsageWindowKind, + pub label: String, + pub estimated_used_units: u32, + pub estimated_limit_units: Option, + pub reset_at: Option, + pub source: UsageEstimateSource, +} + +impl AccountUsageWindow { + pub fn pressure_permille(&self) -> Option { + let limit = self.estimated_limit_units?; + if limit == 0 { + return None; + } + + let used = self.estimated_used_units.min(limit); + let permille = (u64::from(used) * 1000) / u64::from(limit); + u16::try_from(permille).ok() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRecord { + pub id: String, + pub alias: String, + pub masked_email: Option, + pub plan_label: Option, + pub priority: u32, + pub enabled: bool, + pub cooldown_until: Option, + pub last_limit_error_at: Option, + pub last_selected_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invalid_reason: Option, + pub usage_windows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountManagementProfile { + pub id: String, + pub alias: Option, + pub masked_email: Option, + pub plan_label: Option, + pub priority: Option, +} + +impl AccountRecord { + pub fn display_name(&self) -> &str { + if self.alias.trim().is_empty() { + &self.id + } else { + &self.alias + } + } + + pub fn is_invalid(&self) -> bool { + self.invalid_reason.is_some() + } + + pub fn is_available_at(&self, now_ts: i64) -> bool { + if !self.enabled { + return false; + } + + match self.cooldown_until { + Some(cooldown_until) => cooldown_until <= now_ts, + None => true, + } + } + + pub fn highest_pressure_permille(&self) -> Option { + self.usage_windows + .iter() + .filter_map(AccountUsageWindow::pressure_permille) + .max() + } + + pub fn usage_summary(&self) -> Option { + usage_summary_from_windows(&self.usage_windows) + } +} + +pub fn usage_summary_from_rate_limit_snapshot( + snapshot: &AccountRateLimitSnapshot, +) -> Option { + let windows = rate_limit_windows(snapshot); + usage_summary_from_windows(&windows) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountPoolState { + pub version: u32, + pub active_account_id: Option, + pub accounts: Vec, +} + +impl Default for AccountPoolState { + fn default() -> Self { + Self { + version: ACCOUNT_POOL_STATE_VERSION, + active_account_id: None, + accounts: Vec::new(), + } + } +} + +impl AccountPoolState { + pub fn upsert_account(&mut self, profile: AccountManagementProfile) -> bool { + if let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == profile.id) + { + let mut changed = false; + let next_alias = profile.alias.unwrap_or_else(|| account.alias.clone()); + if account.alias != next_alias { + account.alias = next_alias; + changed = true; + } + if account.masked_email != profile.masked_email { + account.masked_email = profile.masked_email; + changed = true; + } + if account.plan_label != profile.plan_label { + account.plan_label = profile.plan_label; + changed = true; + } + if let Some(priority) = profile.priority + && account.priority != priority + { + account.priority = priority; + changed = true; + } + return changed; + } + + self.accounts.push(AccountRecord { + id: profile.id.clone(), + alias: profile.alias.unwrap_or(profile.id.clone()), + masked_email: profile.masked_email, + plan_label: profile.plan_label, + priority: profile.priority.unwrap_or_else(|| { + u32::try_from(self.accounts.len()).unwrap_or(u32::MAX.saturating_sub(1)) + }), + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: None, + invalid_reason: None, + usage_windows: Vec::new(), + }); + if self.active_account_id.is_none() { + self.active_account_id = Some(profile.id); + } + true + } + + pub fn set_active_account(&mut self, account_id: &str, now_ts: i64) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + account.last_selected_at = Some(now_ts); + let should_change = self.active_account_id.as_deref() != Some(account_id); + self.active_account_id = Some(account_id.to_string()); + should_change || account.last_selected_at == Some(now_ts) + } + + pub fn rename_account_alias(&mut self, account_id: &str, alias: String) -> bool { + let normalized = alias.trim(); + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + let next_alias = if normalized.is_empty() { + account.id.clone() + } else { + normalized.to_string() + }; + if account.alias == next_alias { + false + } else { + account.alias = next_alias; + true + } + } + + pub fn remove_account(&mut self, account_id: &str) -> bool { + let original_len = self.accounts.len(); + self.accounts.retain(|account| account.id != account_id); + if self.accounts.len() == original_len { + return false; + } + + if self.active_account_id.as_deref() == Some(account_id) { + self.active_account_id = self.accounts.first().map(|account| account.id.clone()); + } + + true + } + + pub fn apply_rate_limit_snapshot( + &mut self, + account_id: &str, + snapshot: &AccountRateLimitSnapshot, + ) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + let previous = account.usage_windows.clone(); + account.usage_windows = rate_limit_windows(snapshot); + let usage_changed = previous != account.usage_windows; + let invalid_changed = account.invalid_reason.take().is_some(); + usage_changed || invalid_changed + } + + pub fn set_invalid_reason(&mut self, account_id: &str, invalid_reason: Option) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + + let next_invalid_reason = invalid_reason + .map(|reason| reason.trim().to_string()) + .filter(|reason| !reason.is_empty()); + let changed = account.invalid_reason != next_invalid_reason; + if changed { + account.invalid_reason = next_invalid_reason; + } + if account.invalid_reason.is_some() && !account.usage_windows.is_empty() { + account.usage_windows.clear(); + return true; + } + changed + } + + pub fn set_plan_label(&mut self, account_id: &str, plan_label: Option) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + + if account.plan_label == plan_label { + false + } else { + account.plan_label = plan_label; + true + } + } + + pub fn apply_limit_signal(&mut self, account_id: &str, signal: &AccountLimitSignal) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + + let mut changed = false; + if account.last_limit_error_at != Some(signal.recorded_at) { + account.last_limit_error_at = Some(signal.recorded_at); + changed = true; + } + if account.cooldown_until != signal.cooldown_until { + account.cooldown_until = signal.cooldown_until; + changed = true; + } + changed + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountPoolStore { + codex_home: PathBuf, +} + +impl AccountPoolStore { + pub fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + pub fn path(&self) -> PathBuf { + self.codex_home.join(ACCOUNT_POOL_STATE_RELATIVE_PATH) + } + + pub fn load(&self) -> io::Result { + let path = self.path(); + match fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents).map_err(io::Error::other), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(AccountPoolState::default()), + Err(err) => Err(err), + } + } + + pub fn save(&self, state: &AccountPoolState) -> io::Result<()> { + let path = self.path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(state).map_err(io::Error::other)?; + fs::write(path, contents) + } + + pub fn update(&self, updater: F) -> io::Result + where + F: FnOnce(&mut AccountPoolState), + { + let mut state = self.load()?; + updater(&mut state); + self.save(&state)?; + Ok(state) + } +} + +fn rate_limit_windows(snapshot: &AccountRateLimitSnapshot) -> Vec { + let mut windows = Vec::new(); + for (kind, label, window) in [ + ( + AccountUsageWindowKind::FiveHour, + "5h", + snapshot.primary.as_ref(), + ), + ( + AccountUsageWindowKind::Weekly, + "week", + snapshot.secondary.as_ref(), + ), + ] { + let Some(window) = window else { + continue; + }; + windows.push(AccountUsageWindow { + kind, + label: label.to_string(), + estimated_used_units: window.used_percent.round() as u32, + estimated_limit_units: Some(100), + reset_at: window.resets_at, + source: UsageEstimateSource::ResponseErrorInference, + }); + } + windows +} + +fn usage_summary_from_windows(windows: &[AccountUsageWindow]) -> Option { + let windows = windows + .iter() + .map(|window| { + let prefix = match window.kind { + AccountUsageWindowKind::FiveHour => "5h", + AccountUsageWindowKind::Weekly => "week", + AccountUsageWindowKind::Custom => window.label.as_str(), + }; + match window.estimated_limit_units { + Some(limit) => format!("{prefix} {}/{}", window.estimated_used_units, limit), + None => format!("{prefix} {}", window.estimated_used_units), + } + }) + .collect::>(); + if windows.is_empty() { + None + } else { + Some(windows.join(" · ")) + } +} + +#[cfg(test)] +mod tests { + use super::AccountManagementProfile; + use super::AccountPoolState; + use super::AccountPoolStore; + use super::AccountRateLimitSnapshot; + use super::AccountRateLimitWindow; + use super::AccountRecord; + use super::AccountUsageWindow; + use super::AccountUsageWindowKind; + use super::UsageEstimateSource; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn missing_account_pool_file_returns_default_state() { + let tempdir = tempdir().expect("tempdir"); + let store = AccountPoolStore::new(tempdir.path().to_path_buf()); + + assert_eq!(store.load().expect("load"), AccountPoolState::default()); + } + + #[test] + fn save_round_trips_account_pool_state() { + let tempdir = tempdir().expect("tempdir"); + let store = AccountPoolStore::new(tempdir.path().to_path_buf()); + let state = AccountPoolState { + version: 1, + active_account_id: Some("account-1".to_string()), + accounts: vec![AccountRecord { + id: "account-1".to_string(), + alias: "Primary".to_string(), + masked_email: Some("pri***@example.com".to_string()), + plan_label: Some("pro".to_string()), + priority: 0, + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: Some(12), + invalid_reason: None, + usage_windows: vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: 10, + estimated_limit_units: Some(20), + reset_at: Some(100), + source: UsageEstimateSource::Manual, + }], + }], + }; + + store.save(&state).expect("save"); + + assert_eq!(store.load().expect("load"), state); + } + + #[test] + fn upsert_account_preserves_existing_alias_when_profile_has_none() { + let mut state = AccountPoolState { + version: 1, + active_account_id: Some("account-1".to_string()), + accounts: vec![AccountRecord { + id: "account-1".to_string(), + alias: "Primary".to_string(), + masked_email: None, + plan_label: None, + priority: 0, + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: None, + invalid_reason: None, + usage_windows: Vec::new(), + }], + }; + + assert!(state.upsert_account(AccountManagementProfile { + id: "account-1".to_string(), + alias: None, + masked_email: Some("pri***@example.com".to_string()), + plan_label: Some("pro".to_string()), + priority: None, + })); + assert_eq!(state.accounts[0].alias, "Primary"); + } + + #[test] + fn apply_rate_limit_snapshot_rewrites_usage_windows() { + let mut state = AccountPoolState::default(); + state.upsert_account(AccountManagementProfile { + id: "account-1".to_string(), + alias: Some("Primary".to_string()), + masked_email: None, + plan_label: None, + priority: Some(0), + }); + + let changed = state.apply_rate_limit_snapshot( + "account-1", + &AccountRateLimitSnapshot { + limit_name: Some("codex".to_string()), + primary: Some(AccountRateLimitWindow { + used_percent: 45.0, + window_minutes: Some(300), + resets_at: Some(123), + }), + secondary: None, + }, + ); + + assert!(changed); + assert_eq!( + state.accounts[0].usage_windows, + vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: 45, + estimated_limit_units: Some(100), + reset_at: Some(123), + source: UsageEstimateSource::ResponseErrorInference, + }] + ); + } +} diff --git a/codex-rs/accounts/src/account_signal.rs b/codex-rs/accounts/src/account_signal.rs new file mode 100644 index 000000000..a62a09a3e --- /dev/null +++ b/codex-rs/accounts/src/account_signal.rs @@ -0,0 +1,136 @@ +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_GENERIC_LIMIT_COOLDOWN_SECS: i64 = 15 * 60; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRateLimitWindow { + pub used_percent: f64, + pub window_minutes: Option, + pub resets_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRateLimitSnapshot { + pub limit_name: Option, + pub primary: Option, + pub secondary: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LimitSignalKind { + UsageLimit, + RateLimit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountLimitSignal { + pub kind: LimitSignalKind, + pub recorded_at: i64, + pub cooldown_until: Option, +} + +pub fn infer_limit_signal( + kind: LimitSignalKind, + recorded_at: i64, + snapshot: Option<&AccountRateLimitSnapshot>, +) -> AccountLimitSignal { + let cooldown_until = snapshot + .and_then(|snapshot| blocking_resets_at(snapshot, recorded_at)) + .or_else(|| match kind { + LimitSignalKind::UsageLimit => None, + LimitSignalKind::RateLimit => Some(recorded_at + DEFAULT_GENERIC_LIMIT_COOLDOWN_SECS), + }); + + AccountLimitSignal { + kind, + recorded_at, + cooldown_until, + } +} + +fn blocking_resets_at(snapshot: &AccountRateLimitSnapshot, recorded_at: i64) -> Option { + let mut saturated = Vec::new(); + let mut any_future = Vec::new(); + + for window in [snapshot.primary.as_ref(), snapshot.secondary.as_ref()] + .into_iter() + .flatten() + { + let Some(resets_at) = window + .resets_at + .filter(|resets_at| *resets_at > recorded_at) + else { + continue; + }; + + any_future.push(resets_at); + if window.used_percent >= 99.5 { + saturated.push(resets_at); + } + } + + saturated + .into_iter() + .min() + .or_else(|| any_future.into_iter().min()) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::AccountLimitSignal; + use super::AccountRateLimitSnapshot; + use super::AccountRateLimitWindow; + use super::LimitSignalKind; + use super::infer_limit_signal; + + #[test] + fn usage_limit_prefers_saturated_window_reset() { + let signal = infer_limit_signal( + LimitSignalKind::UsageLimit, + 100, + Some(&AccountRateLimitSnapshot { + limit_name: Some("codex".to_string()), + primary: Some(AccountRateLimitWindow { + used_percent: 92.0, + window_minutes: Some(300), + resets_at: Some(500), + }), + secondary: Some(AccountRateLimitWindow { + used_percent: 100.0, + window_minutes: Some(10080), + resets_at: Some(900), + }), + }), + ); + + assert_eq!( + signal, + AccountLimitSignal { + kind: LimitSignalKind::UsageLimit, + recorded_at: 100, + cooldown_until: Some(900), + } + ); + } + + #[test] + fn rate_limit_falls_back_to_short_cooldown_without_snapshot() { + let signal = infer_limit_signal(LimitSignalKind::RateLimit, 100, None); + + assert_eq!( + signal, + AccountLimitSignal { + kind: LimitSignalKind::RateLimit, + recorded_at: 100, + cooldown_until: Some(1000), + } + ); + } +} diff --git a/codex-rs/accounts/src/lib.rs b/codex-rs/accounts/src/lib.rs new file mode 100644 index 000000000..1f42ae1f2 --- /dev/null +++ b/codex-rs/accounts/src/lib.rs @@ -0,0 +1,31 @@ +mod account_pool; +mod account_signal; +mod managed_account_auth; +mod router; + +pub use account_pool::ACCOUNT_POOL_STATE_RELATIVE_PATH; +pub use account_pool::AccountManagementProfile; +pub use account_pool::AccountPoolState; +pub use account_pool::AccountPoolStore; +pub use account_pool::AccountRecord; +pub use account_pool::AccountUsageWindow; +pub use account_pool::AccountUsageWindowKind; +pub use account_pool::UsageEstimateSource; +pub use account_pool::usage_summary_from_rate_limit_snapshot; +pub use account_signal::AccountLimitSignal; +pub use account_signal::AccountRateLimitSnapshot; +pub use account_signal::AccountRateLimitWindow; +pub use account_signal::LimitSignalKind; +pub use account_signal::infer_limit_signal; +pub use managed_account_auth::MANAGED_ACCOUNTS_RELATIVE_DIR; +pub use managed_account_auth::ManagedAccountAuthStore; +pub use managed_account_auth::ManagedAccountSnapshot; +pub use managed_account_auth::activate_managed_account; +pub use managed_account_auth::load_current_managed_account_snapshot; +pub use managed_account_auth::persist_current_managed_account_snapshot; +pub use managed_account_auth::persist_managed_account_auth_snapshot; +pub use router::AccountRouterDecision; +pub use router::AccountRouterDecisionReason; +pub use router::DefaultAccountRouter; +pub use router::RouteTurnRequest; +pub use router::RoutingTrigger; diff --git a/codex-rs/accounts/src/managed_account_auth.rs b/codex-rs/accounts/src/managed_account_auth.rs new file mode 100644 index 000000000..8ca78fb8a --- /dev/null +++ b/codex-rs/accounts/src/managed_account_auth.rs @@ -0,0 +1,271 @@ +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::AuthDotJson; +use codex_core::auth::load_auth_dot_json; +use codex_core::auth::save_auth; +use std::fs; +use std::fs::OpenOptions; +use std::io; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; + +use crate::account_pool::AccountManagementProfile; + +pub const MANAGED_ACCOUNTS_RELATIVE_DIR: &str = "accounts"; +const ACCOUNT_AUTH_FILE_NAME: &str = "auth.json"; + +#[derive(Debug, Clone, PartialEq)] +pub struct ManagedAccountSnapshot { + pub profile: AccountManagementProfile, + pub auth: AuthDotJson, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedAccountAuthStore { + codex_home: PathBuf, +} + +impl ManagedAccountAuthStore { + pub fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + pub fn directory(&self) -> PathBuf { + self.codex_home.join(MANAGED_ACCOUNTS_RELATIVE_DIR) + } + + pub fn account_dir(&self, account_id: &str) -> PathBuf { + self.directory().join(account_id) + } + + pub fn account_auth_path(&self, account_id: &str) -> PathBuf { + self.account_dir(account_id).join(ACCOUNT_AUTH_FILE_NAME) + } + + pub fn load_account_auth(&self, account_id: &str) -> io::Result { + let path = self.account_auth_path(account_id); + let contents = fs::read_to_string(path)?; + serde_json::from_str(&contents).map_err(io::Error::other) + } + + pub fn save_account_auth(&self, account_id: &str, auth: &AuthDotJson) -> io::Result<()> { + let path = self.account_auth_path(account_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let contents = serde_json::to_string_pretty(auth).map_err(io::Error::other)?; + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(contents.as_bytes())?; + file.flush() + } + + pub fn delete_account_auth(&self, account_id: &str) -> io::Result<()> { + let account_dir = self.account_dir(account_id); + match fs::remove_dir_all(account_dir) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } + } +} + +pub fn activate_managed_account( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + account_id: &str, +) -> io::Result<()> { + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + let auth = store.load_account_auth(account_id)?; + save_auth(codex_home, &auth, auth_credentials_store_mode) +} + +pub fn persist_current_managed_account_snapshot( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> io::Result> { + let Some(snapshot) = + load_current_managed_account_snapshot(codex_home, auth_credentials_store_mode)? + else { + return Ok(None); + }; + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + store.save_account_auth(&snapshot.profile.id, &snapshot.auth)?; + Ok(Some(snapshot)) +} + +pub fn persist_managed_account_auth_snapshot( + codex_home: &Path, + auth: &AuthDotJson, + alias_override: Option<&str>, +) -> io::Result> { + let Some(snapshot) = managed_account_snapshot_from_auth(auth, alias_override) else { + return Ok(None); + }; + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + store.save_account_auth(&snapshot.profile.id, &snapshot.auth)?; + Ok(Some(snapshot)) +} + +pub fn load_current_managed_account_snapshot( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> io::Result> { + let Some(auth_json) = load_auth_dot_json(codex_home, auth_credentials_store_mode)? else { + return Ok(None); + }; + Ok(managed_account_snapshot_from_auth( + &auth_json, /*alias_override*/ None, + )) +} + +fn managed_account_snapshot_from_auth( + auth: &AuthDotJson, + alias_override: Option<&str>, +) -> Option { + let tokens = auth.tokens.as_ref()?; + let id = tokens + .account_id + .clone() + .or_else(|| tokens.id_token.chatgpt_account_id.clone())?; + let profile = AccountManagementProfile { + id, + alias: alias_override + .map(str::trim) + .filter(|alias| !alias.is_empty()) + .map(ToString::to_string), + masked_email: tokens.id_token.email.as_deref().map(mask_email), + plan_label: tokens + .id_token + .get_chatgpt_plan_type() + .map(|plan_type| plan_type.to_ascii_lowercase()), + priority: None, + }; + Some(ManagedAccountSnapshot { + profile, + auth: auth.clone(), + }) +} + +fn mask_email(email: &str) -> String { + let mut parts = email.split('@'); + let local = parts.next().unwrap_or_default(); + let domain = parts.next().unwrap_or_default(); + if local.is_empty() || domain.is_empty() { + return email.to_string(); + } + + let prefix: String = local.chars().take(3).collect(); + format!("{prefix}***@{domain}") +} + +#[cfg(test)] +mod tests { + use super::ManagedAccountAuthStore; + use super::activate_managed_account; + use super::persist_current_managed_account_snapshot; + use super::persist_managed_account_auth_snapshot; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use codex_app_server_protocol::AuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::load_auth_dot_json; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use serde_json::json; + + fn chatgpt_auth(account_id: &str, email: &str) -> AuthDotJson { + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&fake_jwt( + email, account_id, "pro", + )) + .expect("id token"), + access_token: fake_jwt(email, account_id, "pro"), + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: None, + } + } + + fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String { + let header = json!({"alg":"none","typ":"JWT"}); + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |value: serde_json::Value| -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&value).expect("serialize")) + }; + format!("{}.{}.sig", encode(header), encode(payload)) + } + + #[test] + fn persist_managed_account_round_trips() { + let tempdir = tempdir().expect("tempdir"); + let store = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()); + let auth = chatgpt_auth("account-1", "person@example.com"); + + persist_managed_account_auth_snapshot(tempdir.path(), &auth, Some("primary")) + .expect("persist snapshot"); + + let loaded = store.load_account_auth("account-1").expect("load auth"); + assert_eq!(loaded, auth); + } + + #[test] + fn persist_current_managed_account_snapshot_saves_active_auth() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("account-1", "person@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::Plaintext).expect("save auth"); + + let snapshot = persist_current_managed_account_snapshot( + tempdir.path(), + AuthCredentialsStoreMode::Plaintext, + ) + .expect("persist current") + .expect("snapshot"); + + assert_eq!(snapshot.profile.id, "account-1"); + } + + #[test] + fn activate_managed_account_restores_saved_auth() { + let tempdir = tempdir().expect("tempdir"); + let store = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()); + let auth = chatgpt_auth("account-1", "person@example.com"); + store + .save_account_auth("account-1", &auth) + .expect("save account auth"); + + activate_managed_account( + tempdir.path(), + AuthCredentialsStoreMode::Plaintext, + "account-1", + ) + .expect("activate"); + + let active = load_auth_dot_json(tempdir.path(), AuthCredentialsStoreMode::Plaintext) + .expect("load auth") + .expect("active auth"); + assert_eq!(active, auth); + } +} diff --git a/codex-rs/accounts/src/router.rs b/codex-rs/accounts/src/router.rs new file mode 100644 index 000000000..51313cc0a --- /dev/null +++ b/codex-rs/accounts/src/router.rs @@ -0,0 +1,246 @@ +use crate::account_pool::AccountPoolState; +use crate::account_pool::AccountRecord; +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_USAGE_THRESHOLD_PERMILLE: u16 = 850; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutingTrigger { + NormalTurn, + RetryAfterHardError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountRouterDecisionReason { + KeepActiveAccount, + RetryWithFallbackAccount, + ActiveAccountCoolingDown, + ActiveAccountOverThreshold, + PreferredFallbackSelected, + NoHealthyAccount, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteTurnRequest { + pub now_ts: i64, + pub trigger: RoutingTrigger, + pub active_account_id: Option, + pub preferred_account_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRouterDecision { + pub account_id: Option, + pub reason: AccountRouterDecisionReason, + pub retry_immediately: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefaultAccountRouter { + usage_threshold_permille: u16, +} + +impl Default for DefaultAccountRouter { + fn default() -> Self { + Self { + usage_threshold_permille: DEFAULT_USAGE_THRESHOLD_PERMILLE, + } + } +} + +impl DefaultAccountRouter { + pub fn new(usage_threshold_permille: u16) -> Self { + Self { + usage_threshold_permille, + } + } + + pub fn select_account( + &self, + state: &AccountPoolState, + request: &RouteTurnRequest, + ) -> AccountRouterDecision { + let active = request + .active_account_id + .as_deref() + .or(state.active_account_id.as_deref()) + .and_then(|account_id| { + state + .accounts + .iter() + .find(|account| account.id == account_id) + }); + + if request.trigger == RoutingTrigger::NormalTurn + && let Some(active) = active + && self.is_healthy(active, request.now_ts) + { + return AccountRouterDecision { + account_id: Some(active.id.clone()), + reason: AccountRouterDecisionReason::KeepActiveAccount, + retry_immediately: false, + }; + } + + if request.trigger == RoutingTrigger::RetryAfterHardError { + if let Some(fallback) = self.pick_fallback(state, request, active) { + return AccountRouterDecision { + account_id: Some(fallback.id.clone()), + reason: AccountRouterDecisionReason::RetryWithFallbackAccount, + retry_immediately: true, + }; + } + + return AccountRouterDecision { + account_id: None, + reason: AccountRouterDecisionReason::NoHealthyAccount, + retry_immediately: false, + }; + } + + if let Some(active) = active { + let reason = if active.is_available_at(request.now_ts) { + AccountRouterDecisionReason::ActiveAccountOverThreshold + } else { + AccountRouterDecisionReason::ActiveAccountCoolingDown + }; + + if let Some(fallback) = self.pick_fallback(state, request, Some(active)) { + return AccountRouterDecision { + account_id: Some(fallback.id.clone()), + reason, + retry_immediately: false, + }; + } + } + + if let Some(preferred) = request + .preferred_account_id + .as_deref() + .and_then(|account_id| { + state + .accounts + .iter() + .find(|account| account.id == account_id) + }) + .filter(|account| self.is_healthy(account, request.now_ts)) + { + return AccountRouterDecision { + account_id: Some(preferred.id.clone()), + reason: AccountRouterDecisionReason::PreferredFallbackSelected, + retry_immediately: false, + }; + } + + AccountRouterDecision { + account_id: None, + reason: AccountRouterDecisionReason::NoHealthyAccount, + retry_immediately: false, + } + } + + fn is_healthy(&self, account: &AccountRecord, now_ts: i64) -> bool { + !account.is_invalid() + && account.is_available_at(now_ts) + && account + .highest_pressure_permille() + .is_none_or(|pressure| pressure < self.usage_threshold_permille) + } + + fn pick_fallback<'a>( + &self, + state: &'a AccountPoolState, + request: &RouteTurnRequest, + active: Option<&AccountRecord>, + ) -> Option<&'a AccountRecord> { + let active_id = active.map(|account| account.id.as_str()); + let mut candidates: Vec<&AccountRecord> = state + .accounts + .iter() + .filter(|account| Some(account.id.as_str()) != active_id) + .filter(|account| self.is_healthy(account, request.now_ts)) + .collect(); + + candidates.sort_by_key(|account| (account.priority, account.last_selected_at.unwrap_or(0))); + candidates.into_iter().next() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::account_pool::AccountPoolState; + use crate::account_pool::AccountRecord; + use crate::account_pool::AccountUsageWindow; + use crate::account_pool::AccountUsageWindowKind; + use crate::account_pool::UsageEstimateSource; + + use super::AccountRouterDecision; + use super::AccountRouterDecisionReason; + use super::DefaultAccountRouter; + use super::RouteTurnRequest; + use super::RoutingTrigger; + + fn account( + id: &str, + priority: u32, + used: u32, + limit: u32, + cooldown_until: Option, + ) -> AccountRecord { + AccountRecord { + id: id.to_string(), + alias: id.to_string(), + masked_email: None, + plan_label: None, + priority, + enabled: true, + cooldown_until, + last_limit_error_at: None, + last_selected_at: None, + invalid_reason: None, + usage_windows: vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: used, + estimated_limit_units: Some(limit), + reset_at: None, + source: UsageEstimateSource::LocalHeuristic, + }], + } + } + + #[test] + fn normal_turn_keeps_healthy_active_account() { + let router = DefaultAccountRouter::default(); + let state = AccountPoolState { + version: 1, + active_account_id: Some("primary".to_string()), + accounts: vec![ + account("primary", 0, 10, 40, None), + account("backup", 1, 1, 40, None), + ], + }; + let request = RouteTurnRequest { + now_ts: 100, + trigger: RoutingTrigger::NormalTurn, + active_account_id: Some("primary".to_string()), + preferred_account_id: None, + }; + + assert_eq!( + router.select_account(&state, &request), + AccountRouterDecision { + account_id: Some("primary".to_string()), + reason: AccountRouterDecisionReason::KeepActiveAccount, + retry_immediately: false, + } + ); + } +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1ca468827..8a691f6a5 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -996,6 +996,9 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives. ### request_user_input +The built-in `question` tool and the legacy `request_user_input` tool both use this request/response flow. +By default, `question` is available in both Default and Plan mode; `request_user_input` remains a legacy compatibility tool that is always available in Plan mode and only available in Default mode when `default_mode_request_user_input` is enabled. + 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. ### 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 8d7ca0261..21f3b08f9 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,10 @@ 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` tool is available in Default mode. The legacy `request_user_input` tool is unavailable in Default mode and will return an error.") + ); Ok(()) } @@ -671,7 +674,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/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e7fe14598..f1e967fde 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -5,6 +5,7 @@ use crate::types::RateLimitStatusPayload; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_client::build_reqwest_client_with_custom_ca; +use codex_core::auth::AuthDotJson; use codex_core::auth::CodexAuth; use codex_core::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; @@ -144,6 +145,23 @@ impl Client { Ok(client) } + pub fn from_auth_dot_json(base_url: impl Into, auth: &AuthDotJson) -> Result { + let Some(tokens) = auth.tokens.as_ref() else { + anyhow::bail!("ChatGPT auth is missing token data."); + }; + let mut client = Self::new(base_url)? + .with_user_agent(get_codex_user_agent()) + .with_bearer_token(tokens.access_token.clone()); + if let Some(account_id) = tokens + .account_id + .clone() + .or_else(|| tokens.id_token.chatgpt_account_id.clone()) + { + client = client.with_chatgpt_account_id(account_id); + } + Ok(client) + } + pub fn with_bearer_token(mut self, token: impl Into) -> Self { self.bearer_token = Some(token.into()); self @@ -265,6 +283,21 @@ impl Client { Ok(Self::rate_limit_snapshots_from_payload(payload)) } + pub async fn get_rate_limits_many_detailed( + &self, + ) -> std::result::Result, RequestError> { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; + let payload: RateLimitStatusPayload = self + .decode_json(&url, &ct, &body) + .map_err(RequestError::from)?; + Ok(Self::rate_limit_snapshots_from_payload(payload)) + } + pub async fn list_tasks( &self, limit: Option, diff --git a/codex-rs/btw/Cargo.toml b/codex-rs/btw/Cargo.toml new file mode 100644 index 000000000..30f830509 --- /dev/null +++ b/codex-rs/btw/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-btw" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_btw" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/btw/src/lib.rs b/codex-rs/btw/src/lib.rs new file mode 100644 index 000000000..5d99f299d --- /dev/null +++ b/codex-rs/btw/src/lib.rs @@ -0,0 +1,187 @@ +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_CONTEXT_BUDGET_TOKENS: usize = 2_000; +const PREVIEW_CHAR_LIMIT: usize = 1_200; +const SUMMARY_MAX_LINES: usize = 4; +const SUMMARY_MAX_CHARS: usize = 500; + +pub 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." +); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwSurface { + SlashOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwVisibility { + Hidden, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwPersistence { + EphemeralOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwToolPolicy { + InheritMinusSideEffects, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwResultAction { + InsertSummary, + InsertFull, + Discard, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BtwPolicy { + pub context_budget_tokens: usize, + pub tool_policy: BtwToolPolicy, + pub result_actions: Vec, +} + +impl Default for BtwPolicy { + fn default() -> Self { + Self { + context_budget_tokens: DEFAULT_CONTEXT_BUDGET_TOKENS, + tool_policy: BtwToolPolicy::InheritMinusSideEffects, + result_actions: vec![ + BtwResultAction::InsertSummary, + BtwResultAction::InsertFull, + BtwResultAction::Discard, + ], + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BtwCapability { + pub enabled: bool, + pub surface: BtwSurface, + pub visibility: BtwVisibility, + pub persistence: BtwPersistence, + pub policy: BtwPolicy, +} + +impl Default for BtwCapability { + fn default() -> Self { + Self { + enabled: true, + surface: BtwSurface::SlashOnly, + visibility: BtwVisibility::Hidden, + persistence: BtwPersistence::EphemeralOnly, + policy: BtwPolicy::default(), + } + } +} + +pub fn merge_developer_instructions(existing: Option) -> String { + match existing { + Some(existing) if !existing.trim().is_empty() => { + format!("{existing}\n\n{BTW_DEVELOPER_INSTRUCTIONS}") + } + _ => BTW_DEVELOPER_INSTRUCTIONS.to_string(), + } +} + +pub 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…") +} + +pub 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")) + } +} + +pub fn full_insert_text(message: &str) -> String { + format!("BTW discussion:\n{message}") +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::BtwCapability; + use super::BtwResultAction; + use super::BtwToolPolicy; + use super::preview_text; + use super::summarize_message; + + #[test] + fn btw_capability_defaults_match_hidden_ephemeral_discussion_flow() { + let capability = BtwCapability::default(); + + assert!(capability.enabled); + assert_eq!(capability.policy.context_budget_tokens, 2_000); + assert_eq!( + capability.policy.tool_policy, + BtwToolPolicy::InheritMinusSideEffects + ); + assert_eq!( + capability.policy.result_actions, + vec![ + BtwResultAction::InsertSummary, + BtwResultAction::InsertFull, + BtwResultAction::Discard, + ] + ); + } + + #[test] + fn summarize_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 message = "a".repeat(1_250); + let preview = preview_text(&message); + + assert!(preview.contains("preview truncated")); + } +} diff --git a/codex-rs/clawbot/Cargo.toml b/codex-rs/clawbot/Cargo.toml new file mode 100644 index 000000000..c779df57d --- /dev/null +++ b/codex-rs/clawbot/Cargo.toml @@ -0,0 +1,46 @@ +[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 } +async-trait = { workspace = true } +openlark = { version = "0.15.0-rc.1", default-features = false, features = [ + "ai", + "analytics", + "application", + "auth", + "base", + "bitable", + "cardkit", + "communication", + "docs", + "helpdesk", + "hr", + "mail", + "meeting", + "platform", + "protocol", + "security", + "user", + "webhook", + "websocket", + "workflow", +] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync"] } +toml = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs new file mode 100644 index 000000000..3bcc28730 --- /dev/null +++ b/codex-rs/clawbot/src/config.rs @@ -0,0 +1,89 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::model::ProviderKind; + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ClawbotTurnMode { + #[default] + Interactive, + NonInteractive, +} + +impl ClawbotTurnMode { + pub fn label(self) -> &'static str { + match self { + Self::Interactive => "interactive", + Self::NonInteractive => "non-interactive", + } + } + + 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, +} + +impl ClawbotConfig { + pub fn has_provider_config(&self, provider: ProviderKind) -> bool { + match provider { + ProviderKind::Feishu => self + .feishu + .as_ref() + .is_some_and(FeishuConfig::has_api_credentials), + } + } +} + +#[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 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()) + } + + pub fn has_api_credentials(&self) -> bool { + !self.app_id.trim().is_empty() && !self.app_secret.trim().is_empty() + } + + pub fn has_gateway_credentials(&self) -> bool { + self.has_api_credentials() + && self + .verification_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()) + } +} 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..b8a992d5f --- /dev/null +++ b/codex-rs/clawbot/src/lib.rs @@ -0,0 +1,38 @@ +mod config; +mod events; +mod model; +mod provider; +mod runtime; +mod store; + +pub use config::ClawbotConfig; +pub use config::ClawbotTurnMode; +pub use config::FeishuConfig; +pub use events::ProviderInboundMessage; +pub use model::CLAWBOT_BINDINGS_RELATIVE_PATH; +pub use model::CLAWBOT_CONFIG_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::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::FeishuInboundPrivateMessage; +pub use provider::FeishuProviderRuntime; +pub use provider::ProviderEvent; +pub use provider::ProviderOutboundAction; +pub use provider::ProviderOutboundReaction; +pub use provider::ProviderOutboundTextMessage; +pub use provider::ProviderReactionReceipt; +pub use provider::ProviderRuntime; +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..b51f6657e --- /dev/null +++ b/codex-rs/clawbot/src/model.rs @@ -0,0 +1,205 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::config::ClawbotConfig; + +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_RUNTIME_RELATIVE_PATH: &str = ".codex/clawbot/runtime.json"; +pub const CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH: &str = ".codex/clawbot/inbound_receipts.json"; + +#[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, +} + +impl ConnectionStatus { + pub fn label(self) -> &'static str { + match self { + Self::Unconfigured => "unconfigured", + Self::Disconnected => "disconnected", + Self::Connecting => "connecting", + Self::Connected => "connected", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionStatus { + #[default] + Discovered, + Bound, + Disconnected, + Error, +} + +impl SessionStatus { + pub fn label(self) -> &'static str { + match self { + Self::Discovered => "discovered", + Self::Bound => "bound", + Self::Disconnected => "disconnected", + Self::Error => "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(), + } + } + + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +#[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, + 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, 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 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()) + } +} + +#[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, +} + +impl ClawbotSnapshot { + pub fn provider_state(&self, provider: ProviderKind) -> Option<&ProviderRuntimeState> { + self.runtime.iter().find(|state| state.provider == provider) + } +} diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs new file mode 100644 index 000000000..87ea8b25c --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -0,0 +1,730 @@ +mod runtime_loop; +mod sync; + +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Result; +use anyhow::anyhow; +use async_trait::async_trait; +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::models::UserIdType; +use open_lark::openlark_communication::im::im::v1::message::reaction::list::ListMessageReactionsRequest; +use open_lark::openlark_communication::im::im::v1::message::reaction::models::CreateMessageReactionBody; +use open_lark::openlark_communication::im::im::v1::message::reaction::models::MessageReaction; +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 super::ProviderReactionReceipt; +use super::ProviderRuntime; +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 FeishuInboundPrivateMessage { + pub chat_id: String, + pub chat_type: String, + pub message_id: String, + pub sender_open_id: Option, + pub sender_user_id: Option, + pub sender_union_id: Option, + pub sender_name: Option, + pub text: String, + pub received_at: i64, +} + +#[derive(Debug)] +pub struct FeishuProviderRuntime { + config: FeishuConfig, + runtime_state: ProviderRuntimeState, +} + +impl FeishuProviderRuntime { + pub fn new(config: FeishuConfig) -> Self { + let runtime_state = if config.has_api_credentials() { + ProviderRuntimeState { + provider: ProviderKind::Feishu, + connection: ConnectionStatus::Disconnected, + last_error: None, + updated_at: None, + } + } else { + ProviderRuntimeState::unconfigured(ProviderKind::Feishu) + }; + + Self { + config, + runtime_state, + } + } + + pub async fn run(self, provider_event_tx: mpsc::UnboundedSender) -> Result<()> { + runtime_loop::run_with_reconnect(self.config, provider_event_tx).await + } + + pub async fn scan_sessions(&mut self) -> Result> { + if !self.config.has_api_credentials() { + let state = self.set_runtime_state( + ConnectionStatus::Unconfigured, + Some("missing app_id/app_secret".to_string()), + )?; + return Ok(vec![ProviderEvent::RuntimeStateUpdated(state)]); + } + + let sync_result = sync::discover_private_sessions(&self.messaging_config()?).await?; + let mut events = Vec::new(); + events.extend( + sync_result + .sessions + .into_iter() + .map(ProviderEvent::SessionUpserted), + ); + Ok(events) + } + + pub fn normalize_private_chat_message( + message: FeishuInboundPrivateMessage, + ) -> Option> { + if !is_private_chat_type(&message.chat_type) || message.text.trim().is_empty() { + return None; + } + + let session = ProviderSession { + provider: ProviderKind::Feishu, + session_id: message.chat_id.clone(), + display_name: message + .sender_name + .or(message.sender_open_id.clone()) + .or(message.sender_user_id.clone()) + .or(message.sender_union_id.clone()), + 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), + ]) + } + + fn websocket_config(&self) -> Result { + runtime_loop::build_websocket_config(&self.config) + } + + fn messaging_config(&self) -> Result { + let config = self.websocket_config()?; + Ok(config.build_core_config_with_token_provider()) + } + + fn set_runtime_state( + &mut self, + connection: ConnectionStatus, + last_error: Option, + ) -> Result { + self.runtime_state.connection = connection; + self.runtime_state.last_error = last_error; + self.runtime_state.updated_at = Some(unix_timestamp_now()?); + Ok(self.runtime_state.clone()) + } +} + +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, 160); + format!("Request failed: {truncated}") +} + +#[async_trait] +impl ProviderRuntime for FeishuProviderRuntime { + fn provider(&self) -> ProviderKind { + ProviderKind::Feishu + } + + fn runtime_state(&self) -> &ProviderRuntimeState { + &self.runtime_state + } + + async fn connect(&mut self) -> Result { + if !self.config.has_api_credentials() { + return self.set_runtime_state( + ConnectionStatus::Unconfigured, + Some("missing app_id/app_secret".to_string()), + ); + } + + self.websocket_config()?; + self.set_runtime_state(ConnectionStatus::Disconnected, None) + } + + async fn disconnect(&mut self) -> Result { + self.set_runtime_state(ConnectionStatus::Disconnected, None) + } + + async fn send_text(&mut self, message: ProviderOutboundTextMessage) -> Result<()> { + if message.session.provider != ProviderKind::Feishu { + return Err(anyhow!( + "cannot send {} message via Feishu runtime", + message.session.provider.title() + )); + } + + let config = self.messaging_config()?; + let body = CreateMessageBody { + receive_id: message.session.session_id, + msg_type: "text".to_string(), + content: serde_json::to_string(&serde_json::json!({ "text": message.text }))?, + uuid: None, + }; + + CreateMessageRequest::new(config) + .receive_id_type(ReceiveIdType::ChatId) + .execute(body) + .await + .map_err(|error| anyhow!("failed to send Feishu text message: {error}"))?; + Ok(()) + } + + async fn add_reaction( + &mut self, + reaction: ProviderOutboundReaction, + ) -> Result { + if reaction.target.provider != ProviderKind::Feishu { + return Err(anyhow!( + "cannot send {} reaction via Feishu runtime", + reaction.target.provider.title() + )); + } + + let config = self.messaging_config()?; + let target = reaction.target; + let emoji_type = reaction.emoji_type; + let request: ApiRequest = + ApiRequest::post(format!("{IM_V1_MESSAGES}/{}/reactions", target.message_id)).body( + serialize_params( + &CreateMessageReactionBody { + reaction_type: ReactionType { + emoji_type: emoji_type.clone(), + }, + }, + "添加消息表情回复", + )?, + ); + let response = open_lark::openlark_core::http::Transport::::request( + request, + &config, + Some(Default::default()), + ) + .await + .map_err(|error| anyhow!("failed to add Feishu message reaction: {error}"))?; + if !response.is_success() { + return Err(anyhow!( + "failed to add Feishu message reaction: {}", + response.msg() + )); + } + + if let Some(data) = response.data().cloned() { + let message_reaction = + serde_json::from_value::(data).map_err(|error| { + anyhow!("failed to parse Feishu message reaction response: {error}") + })?; + return Ok(ProviderReactionReceipt { + target, + reaction_id: message_reaction.reaction_id, + emoji_type: message_reaction.reaction_type.emoji_type, + }); + } + + find_feishu_reaction_receipt(&config, &self.config, &target, &emoji_type) + .await + .map_err(|error| anyhow!("failed to add Feishu message reaction: {error}")) + } + + async fn remove_reaction(&mut self, reaction: ProviderReactionReceipt) -> Result<()> { + if reaction.target.provider != ProviderKind::Feishu { + return Err(anyhow!( + "cannot remove {} reaction via Feishu runtime", + reaction.target.provider.title() + )); + } + + let config = self.messaging_config()?; + let request: ApiRequest = ApiRequest::delete(format!( + "{IM_V1_MESSAGES}/{}/reactions/{}", + reaction.target.message_id, reaction.reaction_id + )); + let response = open_lark::openlark_core::http::Transport::::request( + request, + &config, + Some(Default::default()), + ) + .await + .map_err(|error| anyhow!("failed to remove Feishu message reaction: {error}"))?; + if !response.is_success() { + return Err(anyhow!( + "failed to remove Feishu message reaction: {}", + response.msg() + )); + } + Ok(()) + } +} + +pub(super) fn provider_events_from_payload(payload: &[u8]) -> Vec { + let Ok(envelope) = serde_json::from_slice::(payload) else { + 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 }) + }) + .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() + } + _ => Vec::new(), + } +} + +fn normalize_message_receive_event( + envelope: FeishuMessageReceiveEnvelope, +) -> Option> { + let chat = envelope.event.chat; + let message = envelope.event.message; + if !is_private_chat_type(&message.chat_type) || message.message_type != "text" { + return None; + } + + let chat_id = chat + .as_ref() + .map(|chat| chat.chat_id.clone()) + .or(message.chat_id.clone())?; + let text = serde_json::from_str::(&message.content) + .ok() + .map(|content| content.text) + .unwrap_or_default(); + let received_at = parse_optional_timestamp(Some(message.create_time))?; + + FeishuProviderRuntime::normalize_private_chat_message(FeishuInboundPrivateMessage { + chat_id, + chat_type: message.chat_type, + message_id: message.message_id, + sender_open_id: envelope.event.sender.sender_id.open_id, + sender_user_id: envelope.event.sender.sender_id.user_id, + sender_union_id: envelope.event.sender.sender_id.union_id, + sender_name: chat.and_then(|chat| chat.name), + 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 is_private_chat_type(chat_type: &str) -> bool { + matches!(chat_type, "p2p" | "private") +} + +async fn find_feishu_reaction_receipt( + config: &open_lark::openlark_core::config::Config, + feishu_config: &FeishuConfig, + target: &crate::model::ProviderMessageRef, + emoji_type: &str, +) -> Result { + let mut request = ListMessageReactionsRequest::new(config.clone()) + .message_id(target.message_id.clone()) + .reaction_type(emoji_type) + .page_size(50); + if let Some(user_id_type) = reaction_list_user_id_type(feishu_config) { + request = request.user_id_type(user_id_type); + } + + let response = request + .execute() + .await + .map_err(|error| anyhow!("failed to list Feishu message reactions: {error}"))?; + let expected_operator_id = expected_bot_operator_id(feishu_config); + let reaction = response + .items + .unwrap_or_default() + .into_iter() + .filter(|candidate| candidate.reaction_type.emoji_type == emoji_type) + .find(|candidate| { + expected_operator_id + .is_none_or(|operator_id| candidate.operator.operator_id == operator_id) + }) + .ok_or_else(|| anyhow!("reaction was created but could not be resolved from Feishu"))?; + + Ok(ProviderReactionReceipt { + target: target.clone(), + reaction_id: reaction.reaction_id, + emoji_type: reaction.reaction_type.emoji_type, + }) +} + +fn reaction_list_user_id_type(config: &FeishuConfig) -> Option { + if config + .bot_open_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + Some(UserIdType::OpenId) + } else if config + .bot_user_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + Some(UserIdType::UserId) + } else { + None + } +} + +fn expected_bot_operator_id(config: &FeishuConfig) -> Option<&str> { + config + .bot_open_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + config + .bot_user_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + }) +} + +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, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventMessage { + message_id: String, + create_time: String, + #[serde(default)] + chat_id: Option, + chat_type: String, + message_type: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventChat { + chat_id: String, + #[serde(default)] + name: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuTextContent { + text: String, +} + +#[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 pretty_assertions::assert_eq; + + use super::FeishuInboundPrivateMessage; + use super::failure_reply_text; + use super::is_private_chat_type; + use super::normalize_chat_entered_event; + use super::normalize_message_receive_event; + use super::parse_optional_timestamp; + use crate::model::ProviderKind; + use crate::model::ProviderSession; + use crate::model::ProviderSessionRef; + use crate::model::SessionStatus; + use crate::provider::ProviderEvent; + + #[test] + fn normalize_private_chat_message_creates_session_and_inbound_events() { + let events = super::FeishuProviderRuntime::normalize_private_chat_message( + FeishuInboundPrivateMessage { + chat_id: "chat_123".to_string(), + chat_type: "p2p".to_string(), + message_id: "msg_123".to_string(), + sender_open_id: Some("ou_123".to_string()), + sender_user_id: None, + sender_union_id: None, + sender_name: Some("Alice".to_string()), + 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, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_123".to_string(), + create_time: "456".to_string(), + chat_id: Some("chat_123".to_string()), + chat_type: "p2p".to_string(), + message_type: "image".to_string(), + content: "{}".to_string(), + }, + chat: None, + }, + }; + + assert_eq!(normalize_message_receive_event(envelope), None); + } + + #[test] + fn message_receive_event_reads_chat_id_from_chat_object() { + let events = normalize_message_receive_event(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, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_123".to_string(), + create_time: "456".to_string(), + chat_id: None, + chat_type: "p2p".to_string(), + message_type: "text".to_string(), + content: "{\"text\":\"hello\"}".to_string(), + }, + chat: Some(super::FeishuEventChat { + chat_id: "chat_123".to_string(), + name: Some("机器人".to_string()), + }), + }, + }) + .expect("events"); + + assert_eq!( + events, + vec![ + ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_123".to_string(), + display_name: Some("机器人".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_123"), + message_id: "msg_123".to_string(), + text: "hello".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, + }, + last_message_create_time: Some("789".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(789), + status: SessionStatus::Discovered, + bound_thread_id: None, + })] + ); + } + + #[test] + fn parse_optional_timestamp_returns_none_for_invalid_input() { + assert_eq!( + parse_optional_timestamp(Some("not-a-number".to_string())), + None + ); + } + + #[test] + fn failure_reply_text_uses_first_non_empty_line_and_truncates() { + let message = format!("\n\n{}\nsecond line", "x".repeat(170)); + + assert_eq!( + failure_reply_text(&message), + format!("Request failed: {}…", "x".repeat(160)) + ); + } + + #[test] + fn private_chat_type_accepts_p2p_and_private() { + assert_eq!(is_private_chat_type("p2p"), true); + assert_eq!(is_private_chat_type("private"), true); + assert_eq!(is_private_chat_type("group"), false); + } +} 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..003871a4e --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -0,0 +1,107 @@ +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 super::provider_events_from_payload; +use super::runtime_state; +use super::sync; +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); + +pub(super) async fn run_with_reconnect( + 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(&config, &provider_event_tx).await { + Ok(()) => { + 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 _ = 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( + config: &FeishuConfig, + provider_event_tx: &mpsc::UnboundedSender, +) -> Result<()> { + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Connecting, + None, + )?)); + + let ws_config = Arc::new(build_websocket_config(config)?); + let messaging_config = ws_config.build_core_config_with_token_provider(); + let sync_result = sync::discover_private_sessions(&messaging_config).await?; + for session in sync_result.sessions { + let _ = provider_event_tx.send(ProviderEvent::SessionUpserted(session)); + } + + let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>(); + let event_handler = EventDispatcherHandler::builder() + .payload_sender(payload_tx) + .build(); + let payload_provider_event_tx = provider_event_tx.clone(); + let payload_task = tokio::spawn(async move { + while let Some(payload) = payload_rx.recv().await { + for event in provider_events_from_payload(&payload) { + let _ = payload_provider_event_tx.send(event); + } + } + }); + + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Connected, + sync_result.warning, + )?)); + + let open_result = LarkWsClient::open(ws_config, event_handler).await; + payload_task.abort(); + open_result.map_err(|error| anyhow!("Feishu websocket runtime failed: {error}")) +} + +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}")) +} 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..18e50aac2 --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu/sync.rs @@ -0,0 +1,247 @@ +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; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct FeishuSessionSyncResult { + pub sessions: Vec, + pub warning: Option, +} + +pub(super) async fn discover_private_sessions( + config: &open_lark::openlark_core::config::Config, +) -> Result { + let mut sessions = Vec::new(); + let mut failures = 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(); + let has_more = response.has_more; + + for chat in response.items { + match load_private_session(config, chat).await { + Ok(Some(session)) => sessions.push(session), + Ok(None) => {} + Err(error) => failures.push(error.to_string()), + } + } + + if !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(FeishuSessionSyncResult { + sessions, + warning: summarize_failures(&failures), + }) +} + +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_private_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_private_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 summarize_failures(failures: &[String]) -> Option { + if failures.is_empty() { + return None; + } + + let visible_failures = failures + .iter() + .take(3) + .map(|failure| failure.trim()) + .collect::>(); + let suffix = if failures.len() > visible_failures.len() { + format!(" (+{} more)", failures.len() - visible_failures.len()) + } else { + String::new() + }; + Some(format!( + "failed to inspect some Feishu chats: {}{suffix}", + visible_failures.join("; ") + )) +} + +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") +} + +#[derive(Debug, Deserialize)] +struct FeishuChatListResponse { + #[serde(default)] + items: Vec, + #[serde(default)] + page_token: Option, + #[serde(default)] + has_more: bool, +} + +#[derive(Debug, Deserialize)] +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::FeishuChatListResponse; + use super::first_non_empty; + use super::is_private_chat; + use super::summarize_failures; + + #[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.items.len(), 2); + assert_eq!(response.items[0].chat_id, "oc_1"); + assert_eq!(response.page_token, Some("next_token".to_string())); + assert_eq!(response.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_feishu_chat_type_private() { + assert_eq!( + is_private_chat(&FeishuChatDetails { + name: Some("机器人".to_string()), + chat_mode: Some("group".to_string()), + chat_type: Some("private".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()) + ); + } + + #[test] + fn summarize_failures_limits_error_count() { + assert_eq!( + summarize_failures(&[ + "first".to_string(), + "second".to_string(), + "third".to_string(), + "fourth".to_string(), + ]), + Some("failed to inspect some Feishu chats: first; second; third (+1 more)".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..e1fb47bdd --- /dev/null +++ b/codex-rs/clawbot/src/provider/mod.rs @@ -0,0 +1,64 @@ +mod feishu; + +use anyhow::Result; +use async_trait::async_trait; + +use crate::events::ProviderInboundMessage; +use crate::model::ProviderKind; +use crate::model::ProviderMessageRef; +use crate::model::ProviderRuntimeState; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; + +pub use feishu::FeishuInboundPrivateMessage; +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 struct ProviderReactionReceipt { + pub target: ProviderMessageRef, + pub reaction_id: String, + pub emoji_type: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderOutboundAction { + Text(ProviderOutboundTextMessage), + AddReaction(ProviderOutboundReaction), + RemoveReaction(ProviderReactionReceipt), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderEvent { + RuntimeStateUpdated(ProviderRuntimeState), + SessionUpserted(ProviderSession), + SessionRemoved(ProviderSessionRef), + InboundMessage(ProviderInboundMessage), +} + +#[async_trait] +pub trait ProviderRuntime: Send { + fn provider(&self) -> ProviderKind; + fn runtime_state(&self) -> &ProviderRuntimeState; + + async fn connect(&mut self) -> Result; + async fn disconnect(&mut self) -> Result; + async fn send_text(&mut self, message: ProviderOutboundTextMessage) -> Result<()>; + async fn add_reaction( + &mut self, + reaction: ProviderOutboundReaction, + ) -> Result; + async fn remove_reaction(&mut self, reaction: ProviderReactionReceipt) -> Result<()>; +} diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs new file mode 100644 index 000000000..fa657e241 --- /dev/null +++ b/codex-rs/clawbot/src/runtime.rs @@ -0,0 +1,613 @@ +mod session_admin; + +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; + +use crate::config::ClawbotTurnMode; +use crate::config::FeishuConfig; +use crate::model::CachedUnreadMessage; +use crate::model::ClawbotSnapshot; +use crate::model::InboundMessageReceipt; +use crate::model::ProviderRuntimeState; +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(FeishuProviderRuntime::new) + } + + pub fn persist_runtime_state( + &mut self, + state: ProviderRuntimeState, + ) -> Result<&ClawbotSnapshot> { + self.store.upsert_runtime_state(state)?; + 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 cache_unread_message( + &mut self, + message: CachedUnreadMessage, + ) -> Result<&ClawbotSnapshot> { + self.store.append_unread_message(&message)?; + self.reload() + } + + pub fn take_unread_messages( + &mut self, + session: &ProviderSessionRef, + ) -> Result> { + let unread_messages = self.store.take_unread_messages(session)?; + self.reload()?; + Ok(unread_messages) + } + + pub fn update_feishu_config( + &mut self, + config: Option, + ) -> Result<&ClawbotSnapshot> { + self.snapshot.config.feishu = config; + 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 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_session| { + existing_session.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(), + 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.clone()), + }); + } + + self.store.save_bindings(&bindings)?; + self.store.save_sessions(&sessions)?; + self.reload() + } + + pub fn disconnect_session(&mut self, session: &ProviderSessionRef) -> Result<&ClawbotSnapshot> { + let mut provider_session = self + .load_session(session)? + .ok_or_else(|| anyhow!("session `{}` not found", session.session_id))?; + provider_session.bound_thread_id = None; + provider_session.status = SessionStatus::Discovered; + + self.store.remove_binding(session)?; + self.store.upsert_session(provider_session)?; + self.reload() + } + + pub fn bound_session_for_thread(&self, thread_id: &str) -> Result> { + Ok(self + .store + .load_bindings()? + .into_iter() + .find(|binding| binding.thread_id == thread_id) + .map(|binding| binding.session_ref())) + } + + pub fn flush_cached_messages( + &mut self, + session: &ProviderSessionRef, + ) -> Result> { + let cached_messages = self.store.take_unread_messages(session)?; + if let Some(mut provider_session) = self.load_session(session)? { + provider_session.unread_count = provider_session + .unread_count + .saturating_sub(cached_messages.len()); + self.store.upsert_session(provider_session)?; + } + self.reload()?; + Ok(cached_messages) + } + + 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.clone(), + 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 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 std::fs; + + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::ClawbotRuntime; + use crate::config::ClawbotConfig; + use crate::config::FeishuConfig; + use crate::events::ProviderInboundMessage; + use crate::model::CachedUnreadMessage; + use crate::model::ConnectionStatus; + use crate::model::InboundMessageReceipt; + use crate::model::ProviderKind; + use crate::model::ProviderRuntimeState; + use crate::model::ProviderSession; + use crate::model::ProviderSessionRef; + use crate::model::SessionStatus; + use crate::provider::ProviderEvent; + + #[test] + fn connect_flush_and_disconnect_update_binding_and_unread_state() { + let tempdir = tempdir().expect("tempdir"); + let workspace_root = tempdir.path().to_path_buf(); + let mut runtime = ClawbotRuntime::load(workspace_root.clone()).expect("runtime"); + + runtime + .update_feishu_config(Some(FeishuConfig { + app_id: "cli_a".to_string(), + app_secret: "secret".to_string(), + verification_token: Some("verify".to_string()), + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, + })) + .expect("config"); + 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 + .cache_unread_message(CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 1, + }) + .expect("cache"); + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + display_name: Some("Alice".to_string()), + unread_count: 1, + last_message_at: Some(1), + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("session unread"); + + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_1"); + runtime + .connect_session_to_thread(&session, "thread_123".to_string()) + .expect("connect"); + let snapshot = runtime.snapshot(); + assert_eq!(snapshot.bindings.len(), 1); + assert_eq!( + snapshot.sessions[0].bound_thread_id.as_deref(), + Some("thread_123") + ); + assert_eq!(snapshot.sessions[0].status, SessionStatus::Bound); + + let flushed = runtime.flush_cached_messages(&session).expect("flush"); + assert_eq!(flushed.len(), 1); + assert_eq!(runtime.snapshot().unread_message_count, 0); + assert_eq!(runtime.snapshot().sessions[0].unread_count, 0); + + runtime.disconnect_session(&session).expect("disconnect"); + assert_eq!(runtime.snapshot().bindings.len(), 0); + assert_eq!(runtime.snapshot().sessions[0].bound_thread_id, None); + assert_eq!( + runtime.snapshot().sessions[0].status, + SessionStatus::Discovered + ); + } + + #[test] + fn apply_provider_event_preserves_binding_and_updates_unread_count() { + let tempdir = tempdir().expect("tempdir"); + let workspace_root = tempdir.path().to_path_buf(); + let mut runtime = ClawbotRuntime::load(workspace_root.clone()).expect("runtime"); + + fs::create_dir_all(workspace_root.join(".codex/clawbot")).expect("clawbot dir"); + runtime + .store() + .save_config(&ClawbotConfig { + feishu: Some(FeishuConfig { + app_id: "cli_a".to_string(), + app_secret: "secret".to_string(), + verification_token: Some("verify".to_string()), + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, + }), + ..Default::default() + }) + .expect("config"); + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_2".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( + &ProviderSessionRef::new(ProviderKind::Feishu, "chat_2"), + "thread_456".to_string(), + ) + .expect("connect"); + + runtime + .apply_provider_event(ProviderEvent::RuntimeStateUpdated(ProviderRuntimeState { + provider: ProviderKind::Feishu, + connection: ConnectionStatus::Connected, + last_error: None, + updated_at: Some(10), + })) + .expect("runtime state"); + runtime + .apply_provider_event(ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_2".to_string(), + display_name: Some("Bob Updated".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + })) + .expect("session upsert"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_2"), + message_id: "msg_2".to_string(), + text: "hello".to_string(), + received_at: 20, + })) + .expect("inbound"); + + let snapshot = runtime.snapshot(); + assert_eq!(snapshot.runtime[0].connection, ConnectionStatus::Connected); + assert_eq!(snapshot.unread_message_count, 1); + assert_eq!( + snapshot.sessions[0].display_name.as_deref(), + Some("Bob Updated") + ); + assert_eq!( + snapshot.sessions[0].bound_thread_id.as_deref(), + Some("thread_456") + ); + assert_eq!(snapshot.sessions[0].unread_count, 1); + assert_eq!(snapshot.sessions[0].status, SessionStatus::Bound); + } + + #[test] + fn bound_session_for_thread_returns_persisted_binding() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_3".to_string(), + display_name: Some("Carol".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("session"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_3"); + runtime + .connect_session_to_thread(&session, "thread_789".to_string()) + .expect("connect"); + + assert_eq!( + runtime + .bound_session_for_thread("thread_789") + .expect("binding lookup"), + Some(session) + ); + assert_eq!( + runtime + .bound_session_for_thread("thread_missing") + .expect("missing binding lookup"), + None + ); + } + + #[test] + fn connect_session_to_thread_creates_placeholder_and_replaces_existing_thread_binding() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_existing".to_string(), + display_name: Some("Existing".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("existing session"); + runtime + .connect_session_to_thread( + &ProviderSessionRef::new(ProviderKind::Feishu, "chat_existing"), + "thread_manual".to_string(), + ) + .expect("connect existing"); + runtime + .connect_session_to_thread( + &ProviderSessionRef::new(ProviderKind::Feishu, "chat_manual"), + "thread_manual".to_string(), + ) + .expect("connect manual"); + + assert_eq!( + runtime + .bound_session_for_thread("thread_manual") + .expect("binding lookup"), + Some(ProviderSessionRef::new(ProviderKind::Feishu, "chat_manual")) + ); + assert_eq!(runtime.snapshot().bindings.len(), 1); + assert_eq!( + runtime + .snapshot() + .sessions + .iter() + .find(|session| session.session_id == "chat_existing") + .expect("existing session persisted") + .bound_thread_id, + None + ); + assert_eq!( + runtime + .snapshot() + .sessions + .iter() + .find(|session| session.session_id == "chat_manual") + .expect("manual session persisted"), + &ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_manual".to_string(), + display_name: None, + unread_count: 0, + last_message_at: None, + status: SessionStatus::Bound, + bound_thread_id: Some("thread_manual".to_string()), + } + ); + } + + #[test] + fn duplicate_inbound_message_is_ignored_after_receipt_is_recorded() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_dup"), + message_id: "msg_dup".to_string(), + text: "hello".to_string(), + received_at: 20, + })) + .expect("first inbound"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_dup"), + message_id: "msg_dup".to_string(), + text: "hello".to_string(), + received_at: 20, + })) + .expect("duplicate inbound"); + + let snapshot = runtime.snapshot(); + assert_eq!(snapshot.unread_message_count, 1); + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].unread_count, 1); + } +} diff --git a/codex-rs/clawbot/src/runtime/session_admin.rs b/codex-rs/clawbot/src/runtime/session_admin.rs new file mode 100644 index 000000000..e1a7db7d1 --- /dev/null +++ b/codex-rs/clawbot/src/runtime/session_admin.rs @@ -0,0 +1,55 @@ +use std::collections::HashSet; + +use anyhow::Result; + +use super::ClawbotRuntime; +use crate::model::ClawbotSnapshot; +use crate::model::ProviderKind; + +impl ClawbotRuntime { + pub async fn scan_provider_sessions( + &mut self, + provider: ProviderKind, + ) -> Result<&ClawbotSnapshot> { + match provider { + ProviderKind::Feishu => { + let Some(mut provider_runtime) = self.feishu_provider() else { + return self.reload(); + }; + + for event in provider_runtime.scan_sessions().await? { + self.apply_provider_event(event)?; + } + self.reload() + } + } + } + + pub fn clear_unbound_sessions(&mut self, provider: ProviderKind) -> Result<&ClawbotSnapshot> { + let bindings = self.store.load_bindings()?; + let bound_sessions = bindings + .iter() + .map(|binding| binding.session_ref()) + .collect::>(); + let sessions = self + .store + .load_sessions()? + .into_iter() + .filter(|session| { + session.provider != provider || bound_sessions.contains(&session.session_ref()) + }) + .collect::>(); + let unread_messages = self + .store + .load_unread_messages()? + .into_iter() + .filter(|message| { + message.provider != provider || bound_sessions.contains(&message.session_ref()) + }) + .collect::>(); + + self.store.save_sessions(&sessions)?; + self.store.save_unread_messages(&unread_messages)?; + self.reload() + } +} diff --git a/codex-rs/clawbot/src/store.rs b/codex-rs/clawbot/src/store.rs new file mode 100644 index 000000000..a243b21dc --- /dev/null +++ b/codex-rs/clawbot/src/store.rs @@ -0,0 +1,566 @@ +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 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_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::ConnectionStatus; +use crate::model::InboundMessageReceipt; +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 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 runtime = self.load_runtime_states_for_config(&config)?; + 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 clawbot config")?; + self.write_string_file(&self.config_path(), &rendered) + } + + pub fn load_runtime_states(&self) -> Result> { + let config = self.load_config()?; + self.load_runtime_states_for_config(&config) + } + + 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 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 remove_binding(&self, session: &ProviderSessionRef) -> Result> { + let mut bindings = self.load_bindings()?; + bindings.retain(|binding| binding.session_ref() != *session); + 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 message") + }) + .collect::>>()?; + format!("{}\n", lines.join("\n")) + }; + self.write_string_file(&self.unread_messages_path(), &rendered) + } + + pub fn append_unread_message(&self, unread_message: &CachedUnreadMessage) -> Result<()> { + let mut unread_messages = self.load_unread_messages()?; + unread_messages.push(unread_message.clone()); + self.save_unread_messages(&unread_messages) + } + + 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.provider.title().cmp(right.provider.title())) + .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().saturating_sub(MAX_INBOUND_RECEIPTS)); + } + self.write_json_file(&self.inbound_receipts_path(), &receipts) + } + + pub fn take_unread_messages( + &self, + session: &ProviderSessionRef, + ) -> Result> { + let unread_messages = self.load_unread_messages()?; + let mut taken = Vec::new(); + let mut retained = Vec::new(); + + for message in unread_messages { + if message.session_ref() == *session { + taken.push(message); + } else { + retained.push(message); + } + } + + self.save_unread_messages(&retained)?; + Ok(taken) + } + + fn load_runtime_states_for_config( + &self, + config: &ClawbotConfig, + ) -> Result> { + let mut runtime_states: Vec = + read_optional_json_file(&self.runtime_path()) + .with_context(|| format!("failed to load {}", self.runtime_path().display()))?; + if runtime_states.is_empty() { + runtime_states.push(default_provider_state(config, ProviderKind::Feishu)); + } + Ok(runtime_states) + } + + 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 clawbot JSON file")?; + self.write_string_file(path, &rendered) + } + + fn write_string_file(&self, path: &Path, contents: &str) -> Result<()> { + self.ensure_root_dir()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + let temporary_path = path.with_extension(format!("{}.tmp", std::process::id())); + fs::write(&temporary_path, contents) + .with_context(|| format!("failed to write {}", temporary_path.display()))?; + fs::rename(&temporary_path, path).with_context(|| { + format!( + "failed to move {} to {}", + temporary_path.display(), + path.display() + ) + }) + } +} + +fn read_optional_json_file(path: &Path) -> Result +where + T: DeserializeOwned + Default, +{ + if !path.exists() { + return Ok(T::default()); + } + + 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())) +} + +fn default_provider_state(config: &ClawbotConfig, provider: ProviderKind) -> ProviderRuntimeState { + if config.has_provider_config(provider) { + ProviderRuntimeState { + provider, + connection: ConnectionStatus::Disconnected, + last_error: None, + updated_at: None, + } + } else { + ProviderRuntimeState::unconfigured(provider) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::ClawbotStore; + use crate::config::ClawbotConfig; + use crate::config::FeishuConfig; + use crate::model::CachedUnreadMessage; + use crate::model::InboundMessageReceipt; + use crate::model::ProviderKind; + use crate::model::ProviderRuntimeState; + use crate::model::ProviderSession; + use crate::model::ProviderSessionRef; + use crate::model::SessionBinding; + use crate::model::SessionStatus; + + #[test] + fn save_and_load_snapshot_round_trips_workspace_state() { + let tempdir = tempdir().expect("tempdir"); + let store = ClawbotStore::new(tempdir.path()); + + store + .save_config(&ClawbotConfig { + feishu: Some(FeishuConfig { + app_id: "cli_a".to_string(), + app_secret: "secret".to_string(), + verification_token: Some("verify".to_string()), + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, + }), + ..Default::default() + }) + .expect("config"); + store + .save_runtime_states(&[ProviderRuntimeState::unconfigured(ProviderKind::Feishu)]) + .expect("runtime"); + store + .save_sessions(&[ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + display_name: Some("Alice".to_string()), + unread_count: 2, + last_message_at: Some(10), + status: SessionStatus::Bound, + bound_thread_id: Some("thread_1".to_string()), + }]) + .expect("sessions"); + store + .save_bindings(&[SessionBinding { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + thread_id: "thread_1".to_string(), + created_at: 1, + updated_at: 2, + }]) + .expect("bindings"); + store + .save_unread_messages(&[ + CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 11, + }, + CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_2".to_string(), + message_id: "msg_2".to_string(), + text: "world".to_string(), + received_at: 12, + }, + ]) + .expect("unread"); + + let snapshot = store.load_snapshot().expect("snapshot"); + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.bindings.len(), 1); + assert_eq!(snapshot.unread_message_count, 2); + } + + #[test] + fn take_unread_messages_removes_only_target_session() { + let tempdir = tempdir().expect("tempdir"); + let store = ClawbotStore::new(tempdir.path()); + + store + .save_unread_messages(&[ + CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 11, + }, + CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_2".to_string(), + message_id: "msg_2".to_string(), + text: "world".to_string(), + received_at: 12, + }, + ]) + .expect("unread"); + + let taken = store + .take_unread_messages(&ProviderSessionRef::new(ProviderKind::Feishu, "chat_1")) + .expect("take unread"); + assert_eq!(taken.len(), 1); + assert_eq!(taken[0].message_id, "msg_1"); + assert_eq!(store.load_unread_messages().expect("remaining").len(), 1); + assert_eq!( + store.load_unread_messages().expect("remaining")[0].session_id, + "chat_2" + ); + } + + #[test] + fn append_unread_message_dedups_same_message_id() { + let tempdir = tempdir().expect("tempdir"); + let store = ClawbotStore::new(tempdir.path()); + let unread = CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 11, + }; + + store.append_unread_message(&unread).expect("append first"); + store + .append_unread_message(&unread) + .expect("append duplicate"); + + assert_eq!(store.load_unread_messages().expect("unread").len(), 1); + } + + #[test] + fn record_inbound_receipt_dedups_and_limits_history() { + let tempdir = tempdir().expect("tempdir"); + let store = ClawbotStore::new(tempdir.path()); + + store + .record_inbound_receipt(InboundMessageReceipt { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + message_id: "msg_1".to_string(), + received_at: 11, + }) + .expect("record first"); + store + .record_inbound_receipt(InboundMessageReceipt { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + message_id: "msg_1".to_string(), + received_at: 12, + }) + .expect("record duplicate"); + + let receipts = store.load_inbound_receipts().expect("receipts"); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].received_at, 12); + assert!( + store + .has_inbound_receipt( + &ProviderSessionRef::new(ProviderKind::Feishu, "chat_1"), + "msg_1" + ) + .expect("has receipt") + ); + } +} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 7e703efe1..a93d28352 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -19,6 +19,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } +codex-accounts = { workspace = true } codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } @@ -30,6 +31,7 @@ codex-config = { workspace = true } codex-core = { workspace = true } codex-exec = { workspace = true } codex-execpolicy = { workspace = true } +codex-ext = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } @@ -66,6 +68,7 @@ codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows- [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } +base64 = { workspace = true } codex-utils-cargo-bin = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index d0cc1a3a1..3ab7f375e 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -7,10 +7,15 @@ //! into a one-shot CLI command while still producing a durable `codex-login.log` artifact that //! support can request from users. +use codex_accounts::AccountPoolStore; +use codex_accounts::ManagedAccountSnapshot; +use codex_accounts::persist_current_managed_account_snapshot; +use codex_accounts::persist_managed_account_auth_snapshot; use codex_core::CodexAuth; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthMode; use codex_core::auth::CLIENT_ID; +use codex_core::auth::load_auth_dot_json; use codex_core::auth::login_with_api_key; use codex_core::auth::logout; use codex_core::config::Config; @@ -22,7 +27,10 @@ use codex_utils_cli::CliConfigOverrides; use std::fs::OpenOptions; use std::io::IsTerminal; use std::io::Read; +use std::path::Path; use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use tracing_appender::non_blocking; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::EnvFilter; @@ -110,13 +118,67 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { ); } +fn now_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or(i64::MAX) +} + +fn upsert_managed_account_snapshot( + codex_home: &Path, + snapshot: &ManagedAccountSnapshot, + set_active: bool, +) -> std::io::Result<()> { + let account_id = snapshot.profile.id.clone(); + AccountPoolStore::new(codex_home.to_path_buf()).update(|state| { + state.upsert_account(snapshot.profile.clone()); + if set_active { + state.set_active_account(&account_id, now_timestamp()); + } + })?; + Ok(()) +} + +fn snapshot_existing_managed_account_before_login( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let Some(snapshot) = + persist_current_managed_account_snapshot(codex_home, auth_credentials_store_mode)? + else { + return Ok(()); + }; + upsert_managed_account_snapshot(codex_home, &snapshot, /*set_active*/ false) +} + +fn register_current_managed_account_after_login( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + managed_account_alias: Option<&str>, +) -> std::io::Result<()> { + let Some(auth) = load_auth_dot_json(codex_home, auth_credentials_store_mode)? else { + return Ok(()); + }; + let Some(snapshot) = + persist_managed_account_auth_snapshot(codex_home, &auth, managed_account_alias)? + else { + return Ok(()); + }; + upsert_managed_account_snapshot(codex_home, &snapshot, /*set_active*/ true) +} + pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + managed_account_alias: Option, ) -> std::io::Result<()> { + snapshot_existing_managed_account_before_login(&codex_home, cli_auth_credentials_store_mode)?; + let opts = ServerOptions::new( - codex_home, + codex_home.clone(), CLIENT_ID.to_string(), forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, @@ -125,10 +187,18 @@ pub async fn login_with_chatgpt( print_login_server_start(server.actual_port, &server.auth_url); - server.block_until_done().await + server.block_until_done().await?; + register_current_managed_account_after_login( + &codex_home, + cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) } -pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { +pub async fn run_login_with_chatgpt( + cli_config_overrides: CliConfigOverrides, + managed_account_alias: Option, +) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting browser login flow"); @@ -144,6 +214,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> config.codex_home, forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + managed_account_alias, ) .await { @@ -219,6 +290,7 @@ pub async fn run_login_with_device_code( cli_config_overrides: CliConfigOverrides, issuer_base_url: Option, client_id: Option, + managed_account_alias: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); @@ -227,6 +299,15 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + if let Err(err) = snapshot_existing_managed_account_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + ) { + eprintln!("Error preparing managed account snapshots: {err}"); + std::process::exit(1); + } + + let codex_home = config.codex_home.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home, @@ -239,6 +320,14 @@ pub async fn run_login_with_device_code( } match run_device_code_login(opts).await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -257,6 +346,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( cli_config_overrides: CliConfigOverrides, issuer_base_url: Option, client_id: Option, + managed_account_alias: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); @@ -265,7 +355,15 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + if let Err(err) = snapshot_existing_managed_account_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + ) { + eprintln!("Error preparing managed account snapshots: {err}"); + std::process::exit(1); + } + let codex_home = config.codex_home.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home, @@ -280,6 +378,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( match run_device_code_login(opts.clone()).await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -291,6 +397,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( print_login_server_start(server.actual_port, &server.auth_url); match server.block_until_done().await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -392,6 +506,20 @@ fn safe_format_key(key: &str) -> String { #[cfg(test)] mod tests { + use codex_app_server_protocol::AuthMode as ApiAuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use codex_ext::AccountPoolStore; + use codex_ext::ManagedAccountAuthStore; + use pretty_assertions::assert_eq; + use serde_json::json; + use tempfile::tempdir; + + use crate::login::register_current_managed_account_after_login; + use crate::login::snapshot_existing_managed_account_before_login; + use super::safe_format_key; #[test] @@ -405,4 +533,86 @@ mod tests { let key = "sk-proj-12345"; assert_eq!(safe_format_key(key), "***"); } + + fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String { + let header = json!({"alg":"none","typ":"JWT"}); + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |value: serde_json::Value| -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&value).expect("serialize")) + }; + format!("{}.{}.sig", encode(header), encode(payload)) + } + + fn chatgpt_auth(account_id: &str, email: &str) -> AuthDotJson { + AuthDotJson { + auth_mode: Some(ApiAuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&fake_jwt( + email, account_id, "pro", + )) + .expect("id token"), + access_token: fake_jwt(email, account_id, "pro"), + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: None, + } + } + + #[test] + fn snapshot_existing_managed_account_before_login_copies_root_auth() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("acct-existing", "existing@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::File).expect("save root auth"); + + snapshot_existing_managed_account_before_login( + tempdir.path(), + AuthCredentialsStoreMode::File, + ) + .expect("snapshot existing auth"); + + let managed_auth = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()) + .load_account_auth("acct-existing") + .expect("managed auth"); + let pool = AccountPoolStore::new(tempdir.path().to_path_buf()) + .load() + .expect("load account pool"); + + assert_eq!(managed_auth, auth); + assert_eq!(pool.accounts[0].alias, "acct-existing"); + } + + #[test] + fn register_current_managed_account_after_login_saves_alias_and_marks_active() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("acct-primary", "primary@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::File).expect("save root auth"); + + register_current_managed_account_after_login( + tempdir.path(), + AuthCredentialsStoreMode::File, + Some("Primary"), + ) + .expect("register managed account"); + + let managed_auth = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()) + .load_account_auth("acct-primary") + .expect("managed auth"); + let pool = AccountPoolStore::new(tempdir.path().to_path_buf()) + .load() + .expect("load account pool"); + + assert_eq!(managed_auth, auth); + assert_eq!(pool.active_account_id.as_deref(), Some("acct-primary")); + assert_eq!(pool.accounts[0].alias, "Primary"); + } } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 12a531d35..965f3a968 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -279,6 +279,13 @@ struct LoginCommand { #[clap(skip)] config_overrides: CliConfigOverrides, + #[arg( + long = "auth", + value_name = "ALIAS", + help = "Save this ChatGPT login into the managed account pool with the given alias" + )] + auth: Option, + #[arg( long = "with-api-key", help = "Read the API key from stdin (e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`)" @@ -443,6 +450,26 @@ 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, + &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 { @@ -451,6 +478,7 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { std::process::exit(1); } ExitReason::UserRequested => { /* normal exit */ } + ExitReason::RespawnRequested => unreachable!("respawn should be handled before formatting"), } let update_action = exit_info.update_action; @@ -464,6 +492,48 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { Ok(()) } +fn respawn_current_codex_session( + arg0_paths: &Arg0DispatchPaths, + 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.arg("resume").arg(thread_id); + if respawn_with_yolo { + command.arg("--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(()) + } +} + /// Run the update action and print the result. fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { println!(); @@ -627,7 +697,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)?; } Some(Subcommand::Exec(mut exec_cli)) => { reject_remote_mode_for_subcommand( @@ -757,7 +827,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)?; } Some(Subcommand::Fork(ForkCommand { session_id, @@ -783,7 +853,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)?; } Some(Subcommand::Login(mut login_cli)) => { reject_remote_mode_for_subcommand( @@ -805,6 +875,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { login_cli.config_overrides, login_cli.issuer_base_url, login_cli.client_id, + login_cli.auth, ) .await; } else if login_cli.api_key.is_some() { @@ -813,10 +884,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ); std::process::exit(1); } else if login_cli.with_api_key { + if login_cli.auth.is_some() { + eprintln!("The --auth flag is only supported for ChatGPT login flows."); + std::process::exit(1); + } let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; } else { - run_login_with_chatgpt(login_cli.config_overrides).await; + run_login_with_chatgpt(login_cli.config_overrides, login_cli.auth).await; } } } @@ -1312,6 +1387,7 @@ fn into_legacy_update_action( fn into_legacy_exit_reason(reason: codex_tui_app_server::ExitReason) -> ExitReason { match reason { codex_tui_app_server::ExitReason::UserRequested => ExitReason::UserRequested, + codex_tui_app_server::ExitReason::RespawnRequested => ExitReason::RespawnRequested, codex_tui_app_server::ExitReason::Fatal(message) => ExitReason::Fatal(message), } } @@ -1322,6 +1398,7 @@ fn into_legacy_app_exit_info(exit_info: codex_tui_app_server::AppExitInfo) -> Ap thread_id: exit_info.thread_id, thread_name: exit_info.thread_name, update_action: exit_info.update_action.map(into_legacy_update_action), + respawn_with_yolo: exit_info.respawn_with_yolo, exit_reason: into_legacy_exit_reason(exit_info.exit_reason), } } @@ -1575,6 +1652,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, } } @@ -1586,6 +1664,7 @@ 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, false); diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 25e44d763..04283cab8 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -39,6 +39,8 @@ codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } +codex-loop = { workspace = true } +codex-loop-runtime = { workspace = true } codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1d1d6dd9a..35aab2871 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1670,6 +1670,31 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "display_preferences": { + "allOf": [ + { + "$ref": "#/definitions/TuiDisplayPreferences" + } + ], + "default": { + "show_exec_commands": true, + "show_patch_diffs": true, + "show_tool_results": true, + "show_waited_messages": true + }, + "description": "Transcript visibility preferences that affect only TUI rendering." + }, + "loop": { + "allOf": [ + { + "$ref": "#/definitions/TuiLoopConfig" + } + ], + "default": { + "completion_mirror_mode": "prompt-and-response" + }, + "description": "Controls how completed `/loop` runs are mirrored back into the main thread." + }, "model_availability_nux": { "allOf": [ { @@ -1726,6 +1751,54 @@ }, "type": "object" }, + "TuiDisplayPreferences": { + "additionalProperties": false, + "properties": { + "show_exec_commands": { + "default": true, + "description": "Show command execution transcript cells. Defaults to `true`.", + "type": "boolean" + }, + "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" + }, + "show_waited_messages": { + "default": true, + "description": "Show \"Waited for ...\" transcript messages for background terminal interactions. Defaults to `true`.", + "type": "boolean" + } + }, + "type": "object" + }, + "TuiLoopCompletionMirrorMode": { + "enum": [ + "prompt-and-response", + "response-only" + ], + "type": "string" + }, + "TuiLoopConfig": { + "additionalProperties": false, + "properties": { + "completion_mirror_mode": { + "allOf": [ + { + "$ref": "#/definitions/TuiLoopCompletionMirrorMode" + } + ], + "default": "prompt-and-response", + "description": "Controls what `/loop` mirrors back into the main thread after a scheduled run finishes. Defaults to `prompt-and-response`." + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { @@ -2574,4 +2647,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a3b989768..9c0382ea3 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -5,10 +5,8 @@ use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::codex_thread::ThreadConfigSnapshot; -use crate::context_manager::is_user_turn_boundary; use crate::error::CodexErr; use crate::error::Result as CodexResult; -use crate::event_mapping::parse_turn_item; use crate::find_archived_thread_path_by_id_str; use crate::find_thread_path_by_id_str; use crate::rollout::RolloutRecorder; @@ -20,10 +18,7 @@ use crate::thread_manager::ThreadManagerState; use codex_features::Feature; use codex_protocol::AgentPath; use codex_protocol::ThreadId; -use codex_protocol::items::TurnItem; -use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; @@ -34,7 +29,6 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; use codex_state::DirectionalThreadSpawnEdgeStatus; -use serde::Serialize; use std::collections::HashMap; use std::collections::VecDeque; use std::sync::Arc; @@ -42,6 +36,19 @@ use std::sync::Weak; use tokio::sync::watch; use tracing::warn; +#[cfg(test)] +use crate::context_manager::is_user_turn_boundary; +#[cfg(test)] +use crate::event_mapping::parse_turn_item; +#[cfg(test)] +use codex_protocol::items::TurnItem; +#[cfg(test)] +use codex_protocol::models::ContentItem; +#[cfg(test)] +use codex_protocol::models::ResponseInputItem; +#[cfg(test)] +use serde::Serialize; + const AGENT_NAMES: &str = include_str!("agent_names.txt"); const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context."; @@ -57,6 +64,7 @@ pub(crate) struct LiveAgent { pub(crate) status: AgentStatus, } +#[cfg(test)] #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub(crate) struct ListedAgent { pub(crate) agent_name: String, @@ -707,6 +715,7 @@ impl AgentControl { .join("\n") } + #[cfg(test)] pub(crate) async fn list_agents( &self, current_session_source: &SessionSource, @@ -1049,6 +1058,7 @@ fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option, prefix: &AgentPath) -> bool { if prefix.is_root() { return true; @@ -1063,6 +1073,7 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b }) } +#[cfg(test)] async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option { let pending_input = thread.codex.session.pending_input_snapshot().await; if let Some(message) = pending_input @@ -1094,11 +1105,13 @@ async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option Option { let response_item: ResponseItem = item.clone().into(); last_task_message_from_item(&response_item) } +#[cfg(test)] fn last_task_message_from_item(item: &ResponseItem) -> Option { if !is_user_turn_boundary(item) { return None; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index f78c8d08b..c8f6dc413 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -152,6 +152,7 @@ impl AgentRegistry { .cloned() } + #[cfg(test)] pub(crate) fn live_agents(&self) -> Vec { self.active_agents .lock() diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f29d37fe2..ae8958959 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3976,6 +3976,7 @@ impl Session { } } + #[cfg(test)] pub(crate) async fn pending_input_snapshot(&self) -> Vec { let active = self.active_turn.lock().await; match active.as_ref() { @@ -4001,6 +4002,7 @@ impl Session { std::mem::take(&mut *self.idle_pending_input.lock().await) } + #[cfg(test)] pub(crate) async fn queued_response_items_for_next_turn_snapshot( &self, ) -> Vec { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7d2f6c573..7ffc23242 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -14,6 +14,9 @@ use crate::config::types::ModelAvailabilityNuxConfig; use crate::config::types::NotificationMethod; use crate::config::types::Notifications; use crate::config::types::ToolSuggestDiscoverableType; +use crate::config::types::TuiDisplayPreferences; +use crate::config::types::TuiLoopCompletionMirrorMode; +use crate::config::types::TuiLoopConfig; use crate::config_loader::RequirementSource; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; @@ -270,6 +273,8 @@ fn config_toml_deserializes_model_availability_nux() { ("gpt-foo".to_string(), 2), ]), }, + loop_config: TuiLoopConfig::default(), + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -289,6 +294,60 @@ fn runtime_config_defaults_model_availability_nux() { ); } +#[test] +fn runtime_config_loads_tui_display_preferences() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + display_preferences: TuiDisplayPreferences { + show_tool_results: false, + show_exec_commands: false, + show_waited_messages: false, + show_patch_diffs: true, + }, + ..Tui::default() + }), + ..ConfigToml::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").path().to_path_buf(), + ) + .expect("load config"); + + assert_eq!( + cfg.tui_display_preferences, + TuiDisplayPreferences { + show_tool_results: false, + show_exec_commands: false, + show_waited_messages: false, + show_patch_diffs: true, + } + ); +} + +#[test] +fn runtime_config_loads_tui_loop_completion_mirror_mode() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + loop_config: TuiLoopConfig { + completion_mirror_mode: TuiLoopCompletionMirrorMode::ResponseOnly, + }, + ..Tui::default() + }), + ..ConfigToml::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").path().to_path_buf(), + ) + .expect("load config"); + + assert_eq!( + cfg.tui_loop_completion_mirror_mode, + TuiLoopCompletionMirrorMode::ResponseOnly + ); +} + #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" @@ -948,6 +1007,8 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), + loop_config: TuiLoopConfig::default(), + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -4466,6 +4527,8 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode::PromptAndResponse, + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4609,6 +4672,8 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode::PromptAndResponse, + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4750,6 +4815,8 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode::PromptAndResponse, + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(false), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4877,6 +4944,8 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode::PromptAndResponse, + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -6233,6 +6302,10 @@ struct TuiTomlTest { notifications: Notifications, #[serde(default)] notification_method: NotificationMethod, + #[serde(default, rename = "loop")] + loop_config: TuiLoopConfig, + #[serde(default)] + display_preferences: TuiDisplayPreferences, } #[derive(Deserialize, Debug, PartialEq)] @@ -6273,3 +6346,39 @@ fn test_tui_notification_method() { toml::from_str(toml).expect("deserialize notification_method=\"bel\""); assert_eq!(parsed.tui.notification_method, NotificationMethod::Bel); } + +#[test] +fn test_tui_display_preferences() { + let toml = r#" + [tui.display_preferences] + show_tool_results = false + show_exec_commands = false + show_waited_messages = false + show_patch_diffs = true + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize tui.display_preferences"); + assert_eq!( + parsed.tui.display_preferences, + TuiDisplayPreferences { + show_tool_results: false, + show_exec_commands: false, + show_waited_messages: false, + show_patch_diffs: true, + } + ); +} + +#[test] +fn test_tui_loop_completion_mirror_mode() { + let toml = r#" + [tui.loop] + completion_mirror_mode = "response-only" + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize tui.loop"); + assert_eq!( + parsed.tui.loop_config, + TuiLoopConfig { + completion_mirror_mode: TuiLoopCompletionMirrorMode::ResponseOnly, + } + ); +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b2b55feec..9bdb6c023 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -24,6 +24,8 @@ use crate::config::types::SkillsConfig; use crate::config::types::ToolSuggestConfig; use crate::config::types::ToolSuggestDiscoverable; use crate::config::types::Tui; +use crate::config::types::TuiDisplayPreferences; +use crate::config::types::TuiLoopCompletionMirrorMode; use crate::config::types::UriBasedFileOpener; use crate::config::types::WindowsSandboxModeToml; use crate::config::types::WindowsToml; @@ -350,6 +352,12 @@ pub struct Config { /// Persisted startup availability NUX state for model tooltips. pub model_availability_nux: ModelAvailabilityNuxConfig, + /// Transcript visibility preferences that affect only TUI rendering. + pub tui_display_preferences: TuiDisplayPreferences, + + /// Controls what completed `/loop` runs mirror back into the main thread. + pub tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode, + /// Start the TUI in the specified collaboration mode (plan/default). /// Controls whether the TUI uses the terminal's alternate screen buffer. @@ -2719,6 +2727,16 @@ impl Config { .as_ref() .map(|t| t.model_availability_nux.clone()) .unwrap_or_default(), + tui_loop_completion_mirror_mode: cfg + .tui + .as_ref() + .map(|t| t.loop_config.completion_mirror_mode) + .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/config/types.rs b/codex-rs/core/src/config/types.rs index a69fec343..88ff2aac3 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -736,6 +736,58 @@ pub struct ModelAvailabilityNuxConfig { pub shown_count: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TuiLoopCompletionMirrorMode { + #[default] + PromptAndResponse, + ResponseOnly, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiLoopConfig { + /// Controls what `/loop` mirrors back into the main thread after a scheduled run finishes. + /// Defaults to `prompt-and-response`. + #[serde(default)] + pub completion_mirror_mode: TuiLoopCompletionMirrorMode, +} + +#[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 command execution transcript cells. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_exec_commands: bool, + + /// Show "Waited for ..." transcript messages for background terminal interactions. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_waited_messages: 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_exec_commands: true, + show_waited_messages: 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)] @@ -796,6 +848,14 @@ pub struct Tui { /// Startup tooltip availability NUX state persisted by the TUI. #[serde(default)] pub model_availability_nux: ModelAvailabilityNuxConfig, + + /// Controls how completed `/loop` runs are mirrored back into the main thread. + #[serde(default, rename = "loop")] + pub loop_config: TuiLoopConfig, + + /// Transcript visibility preferences that affect only TUI rendering. + #[serde(default)] + pub display_preferences: TuiDisplayPreferences, } const fn default_true() -> bool { diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index dceab9f3b..0dbf0f412 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -84,21 +84,23 @@ fn request_user_input_availability_message( ) -> String { let mode_name = mode.display_name(); if mode.allows_request_user_input() - || (default_mode_request_user_input && mode == ModeKind::Default) + || (mode == ModeKind::Default && default_mode_request_user_input) { - format!("The `request_user_input` tool is available in {mode_name} mode.") + format!("The `question` and `request_user_input` tools are available in {mode_name} mode.") + } else if mode == ModeKind::Default { + "The `question` tool is available in Default mode. The legacy `request_user_input` tool is unavailable in Default mode and will return an error.".to_string() } 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` and `request_user_input` tools are unavailable in {mode_name} mode. If you call either tool while in {mode_name} mode, it 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 (or the legacy `request_user_input` tool) rather than writing a multiple choice question as a textual assistant message. Use `question` when you need a larger structured form or a mix of choices and freeform fields. 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. Use `question` when you need a larger structured form or a mix of choices and freeform fields. 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/core/src/models_manager/collaboration_mode_presets_tests.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs index b0969f6eb..87c19406b 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs @@ -34,7 +34,8 @@ fn default_mode_instructions_replace_mode_names_placeholder() { let expected_availability_message = request_user_input_availability_message(ModeKind::Default, 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("Use `question` when you need a larger structured form")); } #[test] @@ -44,8 +45,11 @@ fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() { .expect("default preset should include instructions") .expect("default instructions should be set"); - assert!(!default_instructions.contains("prefer using the `request_user_input` tool")); - assert!( - default_instructions.contains("ask the user directly with a concise plain-text question") - ); + assert!(default_instructions.contains( + "The `question` tool is available in Default mode. The legacy `request_user_input` tool is unavailable in Default mode and will return an error." + )); + assert!(default_instructions.contains("prefer using the `question` tool")); + assert!(default_instructions.contains( + "For a single simple clarification, ask the user directly with a concise plain-text question" + )); } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index ebfa5a8bb..c9b7ca6ac 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -198,6 +198,7 @@ impl TurnState { } } + #[cfg(test)] pub(crate) fn pending_input_snapshot(&self) -> Vec { self.pending_input.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 7f272f959..9eddf23a5 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -452,6 +452,29 @@ impl ThreadManager { .await } + pub async fn start_thread_with_history_and_source( + &self, + config: Config, + initial_history: InitialHistory, + session_source: SessionSource, + ) -> CodexResult { + Box::pin(self.state.spawn_thread_with_source( + config, + initial_history, + Arc::clone(&self.state.auth_manager), + self.agent_control(), + session_source, + Vec::new(), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + /*inherited_shell_snapshot*/ None, + /*inherited_exec_policy*/ None, + /*parent_trace*/ None, + /*user_shell_override*/ None, + )) + .await + } + pub async fn resume_thread_from_rollout( &self, config: Config, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index c80f5f743..756d84a65 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -271,6 +271,41 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { assert!(manager.list_thread_ids().await.is_empty()); } +#[tokio::test] +async fn start_thread_with_history_and_source_uses_requested_session_source() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.abs(); + config.ephemeral = true; + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), + ); + + let thread = manager + .start_thread_with_history_and_source( + config, + InitialHistory::New, + SessionSource::Custom("btw".to_string()), + ) + .await + .expect("start btw thread"); + + let snapshot = thread.thread.config_snapshot().await; + assert_eq!( + snapshot.session_source, + SessionSource::Custom("btw".to_string()) + ); + assert!(snapshot.ephemeral); +} + #[tokio::test] async fn new_uses_configured_openai_provider_for_model_refresh() { let server = MockServer::start().await; 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..fdb0fce7b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/grep_files.rs @@ -0,0 +1,176 @@ +use std::path::Path; +use std::time::Duration; + +use async_trait::async_trait; +use serde::Deserialize; +use tokio::process::Command; +use tokio::time::timeout; + +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; + +pub struct GrepFilesHandler; + +const DEFAULT_LIMIT: usize = 100; +const MAX_LIMIT: usize = 2000; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +#[derive(Deserialize)] +struct GrepFilesArgs { + pattern: String, + #[serde(default)] + include: Option, + #[serde(default)] + path: Option, + #[serde(default = "default_limit")] + limit: usize, +} + +#[async_trait] +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)?; + + let pattern = args.pattern.trim(); + if pattern.is_empty() { + return Err(FunctionCallError::RespondToModel( + "pattern must not be empty".to_string(), + )); + } + + if args.limit == 0 { + return Err(FunctionCallError::RespondToModel( + "limit must be greater than zero".to_string(), + )); + } + + let limit = args.limit.min(MAX_LIMIT); + let search_path = turn.resolve_path(args.path.clone()); + + verify_path_exists(&search_path).await?; + + let include = args.include.as_deref().map(str::trim).and_then(|val| { + if val.is_empty() { + None + } else { + Some(val.to_string()) + } + }); + + let search_results = + run_rg_search(pattern, include.as_deref(), &search_path, limit, &turn.cwd).await?; + + if search_results.is_empty() { + Ok(FunctionToolOutput::from_text( + "No matches found.".to_string(), + Some(false), + )) + } else { + Ok(FunctionToolOutput::from_text( + search_results.join("\n"), + Some(true), + )) + } + } +} + +async fn verify_path_exists(path: &Path) -> Result<(), FunctionCallError> { + tokio::fs::metadata(path).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("unable to access `{}`: {err}", path.display())) + })?; + Ok(()) +} + +async fn run_rg_search( + pattern: &str, + include: Option<&str>, + search_path: &Path, + limit: usize, + cwd: &Path, +) -> Result, FunctionCallError> { + let mut command = Command::new("rg"); + command + .current_dir(cwd) + .arg("--files-with-matches") + .arg("--sortr=modified") + .arg("--regexp") + .arg(pattern) + .arg("--no-messages"); + + if let Some(glob) = include { + command.arg("--glob").arg(glob); + } + + command.arg("--").arg(search_path); + + let output = timeout(COMMAND_TIMEOUT, command.output()) + .await + .map_err(|_| { + FunctionCallError::RespondToModel("rg timed out after 30 seconds".to_string()) + })? + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to launch rg: {err}. Ensure ripgrep is installed and on PATH." + )) + })?; + + match output.status.code() { + Some(0) => Ok(parse_results(&output.stdout, limit)), + Some(1) => Ok(Vec::new()), + _ => { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(FunctionCallError::RespondToModel(format!( + "rg failed: {stderr}" + ))) + } + } +} + +fn parse_results(stdout: &[u8], limit: usize) -> Vec { + let mut results = Vec::new(); + for line in stdout.split(|byte| *byte == b'\n') { + if line.is_empty() { + continue; + } + if let Ok(text) = std::str::from_utf8(line) { + if text.is_empty() { + continue; + } + results.push(text.to_string()); + if results.len() == limit { + break; + } + } + } + results +} + +#[cfg(test)] +#[path = "grep_files_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/loop_tool.rs b/codex-rs/core/src/tools/handlers/loop_tool.rs new file mode 100644 index 000000000..02ee644cd --- /dev/null +++ b/codex-rs/core/src/tools/handlers/loop_tool.rs @@ -0,0 +1,213 @@ +use async_trait::async_trait; +use codex_loop::LoopContextMode; +use codex_loop::LoopResponseMode; +use codex_loop::LoopSecurityMode; +use codex_loop_runtime::CreateLoopRequest; +use codex_loop_runtime::CreateLoopResult; +use codex_loop_runtime::CreateLoopServiceError; +use codex_loop_runtime::CreateLoopTriggerRequest; +use codex_loop_runtime::DeleteLoopResult; +use codex_loop_runtime::LoopInfo; +use codex_loop_runtime::LoopSummary; +use codex_loop_runtime::UpdateLoopRequest; +use codex_loop_runtime::create_loop; +use codex_loop_runtime::delete_loop; +use codex_loop_runtime::get_loop; +use codex_loop_runtime::list_loops; +use codex_loop_runtime::update_loop; +use serde::Deserialize; +use serde::Serialize; + +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; + +pub struct LoopToolHandler; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum LoopToolOp { + Create, + List, + Info, + Update, + Delete, +} + +#[derive(Debug, Deserialize)] +struct LoopToolArgs { + op: LoopToolOp, + #[serde(default)] + id: Option, + #[serde(default)] + create: Option, + #[serde(default)] + update: Option, +} + +#[derive(Debug, Deserialize)] +struct LoopToolUpdateArgs { + #[serde(default)] + prompt: Option, + #[serde(default)] + action: Option>, + #[serde(default)] + context_mode: Option, + #[serde(default)] + response_mode: Option, + #[serde(default)] + security_mode: Option, + #[serde(default)] + cwd: Option>, + #[serde(default)] + writable_roots: Option>, + #[serde(default)] + enabled: Option, + #[serde(default)] + trigger_bindings: Option>, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "op", rename_all = "snake_case")] +enum LoopToolResult { + Create { created: CreateLoopResult }, + List { loops: Vec }, + Info { info: LoopInfo }, + Update { updated: LoopInfo }, + Delete { deleted: DeleteLoopResult }, +} + +#[async_trait] +impl ToolHandler for LoopToolHandler { + 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( + "loop handler received unsupported payload".to_string(), + )); + } + }; + + let args: LoopToolArgs = parse_arguments(&arguments)?; + let result = match args.op { + LoopToolOp::Create => { + let Some(create_request) = args.create else { + return Err(FunctionCallError::RespondToModel( + "loop op=create requires the create field".to_string(), + )); + }; + LoopToolResult::Create { + created: create_loop(create_request, turn.cwd.as_path()).map_err(loop_error)?, + } + } + LoopToolOp::List => LoopToolResult::List { + loops: list_loops(turn.cwd.as_path()).map_err(loop_error)?, + }, + LoopToolOp::Info => { + let id = required_loop_id(args.id, "info")?; + LoopToolResult::Info { + info: get_loop(&id, turn.cwd.as_path()).map_err(loop_error)?, + } + } + LoopToolOp::Update => { + let id = required_loop_id(args.id, "update")?; + let Some(update) = args.update else { + return Err(FunctionCallError::RespondToModel( + "loop op=update requires the update field".to_string(), + )); + }; + LoopToolResult::Update { + updated: update_loop( + UpdateLoopRequest { + id, + prompt: update.prompt, + action: update.action, + context_mode: update.context_mode, + response_mode: update.response_mode, + security_mode: update.security_mode, + cwd: update.cwd, + writable_roots: update.writable_roots, + enabled: update.enabled, + trigger_bindings: update.trigger_bindings, + }, + turn.cwd.as_path(), + ) + .map_err(loop_error)?, + } + } + LoopToolOp::Delete => { + let id = required_loop_id(args.id, "delete")?; + LoopToolResult::Delete { + deleted: delete_loop(&id, turn.cwd.as_path()).map_err(loop_error)?, + } + } + }; + + let content = serde_json::to_string(&result).map_err(|err| { + FunctionCallError::Fatal(format!("failed to serialize loop tool result: {err}")) + })?; + Ok(FunctionToolOutput::from_text(content, Some(true))) + } +} + +fn required_loop_id(id: Option, op_name: &str) -> Result { + let id = id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!("loop op={op_name} requires the id field")) + })?; + Ok(id.to_string()) +} + +fn loop_error(err: CreateLoopServiceError) -> FunctionCallError { + FunctionCallError::RespondToModel(err.to_string()) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::CreateLoopTriggerRequest; + use super::LoopToolUpdateArgs; + + #[test] + fn update_request_supports_clearing_optional_fields() { + let update = serde_json::from_value::(serde_json::json!({ + "action": null, + "cwd": null, + "enabled": true, + "trigger_bindings": [ + { + "kind": "timer", + "schedule": "10m" + } + ] + })) + .expect("deserialize update"); + + assert_eq!(update.action, Some(None)); + assert_eq!(update.cwd, Some(None)); + assert_eq!(update.enabled, Some(true)); + assert_eq!( + update.trigger_bindings, + Some(vec![CreateLoopTriggerRequest::Timer { + schedule: "10m".to_string() + }]) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index ba0a0eb50..c9d8bc15b 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -2,14 +2,19 @@ pub(crate) mod agent_jobs; pub mod apply_patch; mod artifacts; mod dynamic; +mod grep_files; mod js_repl; mod list_dir; +mod loop_tool; mod mcp; mod mcp_resource; pub(crate) mod multi_agents; +#[cfg(test)] pub(crate) mod multi_agents_common; +#[cfg(test)] pub(crate) mod multi_agents_v2; mod plan; +mod read_file; mod request_permissions; mod request_user_input; mod shell; @@ -39,15 +44,19 @@ pub use artifacts::ArtifactsHandler; 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 loop_tool::LoopToolHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; +pub use read_file::ReadFileHandler; pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; +pub(crate) use request_user_input::question_tool_description; pub(crate) use request_user_input::request_user_input_tool_description; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 0bf7b7bcd..13611e7d0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -11,33 +11,46 @@ use crate::agent::agent_resolver::resolve_agent_targets; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config::Config; +use crate::error::CodexErr; use crate::function_tool::FunctionCallError; +use crate::models_manager::manager::RefreshStrategy; +use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; -pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; +use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabCloseBeginEvent; use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; pub(crate) use close_agent::Handler as CloseAgentHandler; pub(crate) use resume_agent::Handler as ResumeAgentHandler; @@ -45,12 +58,406 @@ pub(crate) use send_input::Handler as SendInputHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; +/// Minimum wait timeout to prevent tight polling loops from burning CPU. +pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; +pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; +pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; + +fn function_arguments(payload: ToolPayload) -> Result { + match payload { + ToolPayload::Function { arguments } => Ok(arguments), + _ => Err(FunctionCallError::RespondToModel( + "collab handler received unsupported payload".to_string(), + )), + } +} + +fn tool_output_json_text(value: &T, tool_name: &str) -> String +where + T: Serialize, +{ + serde_json::to_string(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string() + }) +} + +fn tool_output_response_item( + call_id: &str, + payload: &ToolPayload, + value: &T, + success: Option, + tool_name: &str, +) -> ResponseInputItem +where + T: Serialize, +{ + FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success) + .to_response_item(call_id, payload) +} + +fn tool_output_code_mode_result(value: &T, tool_name: &str) -> JsonValue +where + T: Serialize, +{ + serde_json::to_value(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")) + }) +} + pub mod close_agent; mod resume_agent; mod send_input; mod spawn; pub(crate) mod wait; +fn build_wait_agent_statuses( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut entries = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if let Some(status) = statuses.get(&receiver_agent.thread_id) { + entries.push(CollabAgentStatusEntry { + thread_id: receiver_agent.thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + status: status.clone(), + }); + } + } + + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains_key(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries +} + +fn collab_spawn_error(err: CodexErr) -> FunctionCallError { + match err { + CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), + err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), + } +} + +fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { + match err { + CodexErr::ThreadNotFound(id) => { + FunctionCallError::RespondToModel(format!("agent with id {id} not found")) + } + CodexErr::InternalAgentDied => { + FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) + } + CodexErr::UnsupportedOperation(_) => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), + } +} + +fn thread_spawn_source( + parent_thread_id: ThreadId, + parent_session_source: &SessionSource, + depth: i32, + agent_role: Option<&str>, + task_name: Option, +) -> Result { + let agent_path = task_name + .as_deref() + .map(|task_name| { + parent_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .join(task_name) + .map_err(FunctionCallError::RespondToModel) + }) + .transpose()?; + Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: None, + agent_role: agent_role.map(str::to_string), + })) +} + +fn parse_collab_input( + message: Option, + items: Option>, +) -> Result, FunctionCallError> { + match (message, items) { + (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( + "Provide either message or items, but not both".to_string(), + )), + (None, None) => Err(FunctionCallError::RespondToModel( + "Provide one of: message or items".to_string(), + )), + (Some(message), None) => { + if message.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "Empty message can't be sent to an agent".to_string(), + )); + } + Ok(vec![UserInput::Text { + text: message, + text_elements: Vec::new(), + }]) + } + (None, Some(items)) => { + if items.is_empty() { + return Err(FunctionCallError::RespondToModel( + "Items can't be empty".to_string(), + )); + } + Ok(items) + } + } +} + +fn input_preview(items: &[UserInput]) -> String { + let parts: Vec = items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::Skill { name, path } => { + format!("[skill:${name}]({})", path.display()) + } + UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + _ => "[input]".to_string(), + }) + .collect(); + + parts.join("\n") +} + +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 +/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, +/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; +/// skipping this helper and cloning stale config state directly can send the child agent out with +/// the wrong provider or runtime policy. +pub(crate) fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + config.base_instructions = Some(base_instructions.text.clone()); + Ok(config) +} + +fn build_agent_resume_config( + turn: &TurnContext, + child_depth: i32, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + apply_spawn_agent_overrides(&mut config, child_depth); + // For resume, keep base instructions sourced from rollout/session metadata. + config.base_instructions = None; + Ok(config) +} + +fn build_agent_shared_config(turn: &TurnContext) -> Result { + let base_config = turn.config.clone(); + let mut config = (*base_config).clone(); + config.model = Some(turn.model_info.slug.clone()); + config.model_provider = turn.provider.clone(); + config.model_reasoning_effort = turn.reasoning_effort; + config.model_reasoning_summary = Some(turn.reasoning_summary); + config.developer_instructions = turn.developer_instructions.clone(); + config.compact_prompt = turn.compact_prompt.clone(); + apply_spawn_agent_runtime_overrides(&mut config, turn)?; + + Ok(config) +} + +/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. +/// +/// These values are chosen by the live turn rather than persisted config, so leaving them stale +/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing. +fn apply_spawn_agent_runtime_overrides( + config: &mut Config, + turn: &TurnContext, +) -> Result<(), FunctionCallError> { + config + .permissions + .approval_policy + .set(turn.approval_policy.value()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) + })?; + config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); + config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); + config.cwd = turn.cwd.clone(); + config + .permissions + .sandbox_policy + .set(turn.sandbox_policy.get().clone()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) + })?; + config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone(); + config.permissions.network_sandbox_policy = turn.network_sandbox_policy; + Ok(()) +} + +fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { + if child_depth >= config.agent_max_depth { + let _ = config.features.disable(Feature::SpawnCsv); + let _ = config.features.disable(Feature::Collab); + } +} + +async fn apply_requested_spawn_agent_model_overrides( + session: &Session, + turn: &TurnContext, + config: &mut Config, + requested_model: Option<&str>, + requested_reasoning_effort: Option, +) -> Result<(), FunctionCallError> { + if requested_model.is_none() && requested_reasoning_effort.is_none() { + return Ok(()); + } + + if let Some(requested_model) = requested_model { + let available_models = session + .services + .models_manager + .list_models(RefreshStrategy::Offline) + .await; + let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; + let selected_model_info = session + .services + .models_manager + .get_model_info(&selected_model_name, config) + .await; + + config.model = Some(selected_model_name.clone()); + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &selected_model_name, + &selected_model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } else { + config.model_reasoning_effort = selected_model_info.default_reasoning_level; + } + + return Ok(()); + } + + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &turn.model_info.slug, + &turn.model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } + + Ok(()) +} + +fn find_spawn_agent_model_name( + available_models: &[codex_protocol::openai_models::ModelPreset], + requested_model: &str, +) -> Result { + available_models + .iter() + .find(|model| model.model == requested_model) + .map(|model| model.model.clone()) + .ok_or_else(|| { + let available = available_models + .iter() + .map(|model| model.model.as_str()) + .collect::>() + .join(", "); + FunctionCallError::RespondToModel(format!( + "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" + )) + }) +} + +fn validate_spawn_agent_reasoning_effort( + model: &str, + supported_reasoning_levels: &[ReasoningEffortPreset], + requested_reasoning_effort: ReasoningEffort, +) -> Result<(), FunctionCallError> { + if supported_reasoning_levels + .iter() + .any(|preset| preset.effort == requested_reasoning_effort) + { + return Ok(()); + } + + let supported = supported_reasoning_levels + .iter() + .map(|preset| preset.effort.to_string()) + .collect::>() + .join(", "); + Err(FunctionCallError::RespondToModel(format!( + "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" + ))) +} + #[cfg(test)] #[path = "multi_agents_tests.rs"] mod tests; 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 53ab4d35f..5bbcdd858 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -72,6 +72,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 @@ -176,6 +184,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 106f34253..8fa72d870 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -225,6 +225,7 @@ pub(crate) fn build_agent_spawn_config( Ok(config) } +#[allow(dead_code)] pub(crate) fn build_agent_resume_config( turn: &TurnContext, child_depth: i32, 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 77ed20dd8..5aa02ce21 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -39,6 +39,7 @@ use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; +use core_test_support::PathExt; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde::Deserialize; @@ -320,6 +321,73 @@ async fn spawn_agent_includes_task_name_key_when_not_named() { 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() + )) + ); +} + #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; @@ -2282,6 +2350,19 @@ async fn build_agent_spawn_config_uses_turn_context_values() { assert_eq!(config, expected); } +#[test] +fn resolve_requested_agent_cwd_resolves_relative_path() { + 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"); + + let resolved = resolve_requested_agent_cwd(temp_dir.path(), Some("worker-a")) + .expect("cwd should resolve") + .expect("cwd should be present"); + + assert_eq!(resolved, child_workspace); +} + #[tokio::test] async fn build_agent_spawn_config_preserves_base_user_instructions() { let (_session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 00ee57976..3f7c27cb0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -30,10 +30,15 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +#[cfg(test)] pub(crate) use assign_task::Handler as AssignTaskHandler; +#[cfg(test)] pub(crate) use list_agents::Handler as ListAgentsHandler; +#[cfg(test)] pub(crate) use send_message::Handler as SendMessageHandler; +#[cfg(test)] pub(crate) use spawn::Handler as SpawnAgentHandler; +#[cfg(test)] pub(crate) use wait::Handler as WaitAgentHandler; mod assign_task; 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..b868edf5b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -0,0 +1,489 @@ +use std::collections::VecDeque; +use std::path::PathBuf; + +use async_trait::async_trait; +use codex_utils_string::take_bytes_at_char_boundary; +use serde::Deserialize; + +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; + +pub struct ReadFileHandler; + +const MAX_LINE_LENGTH: usize = 500; +const TAB_WIDTH: usize = 4; + +// TODO(jif) add support for block comments +const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"]; + +/// JSON arguments accepted by the `read_file` tool handler. +#[derive(Deserialize)] +struct ReadFileArgs { + /// Absolute path to the file that will be read. + file_path: String, + /// 1-indexed line number to start reading from; defaults to 1. + #[serde(default = "defaults::offset")] + offset: usize, + /// Maximum number of lines to return; defaults to 2000. + #[serde(default = "defaults::limit")] + limit: usize, + /// Determines whether the handler reads a simple slice or indentation-aware block. + #[serde(default)] + mode: ReadMode, + /// Optional indentation configuration used when `mode` is `Indentation`. + #[serde(default)] + indentation: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "snake_case")] +enum ReadMode { + #[default] + Slice, + Indentation, +} +/// Additional configuration for indentation-aware reads. +#[derive(Deserialize, Clone)] +struct IndentationArgs { + /// Optional explicit anchor line; defaults to `offset` when omitted. + #[serde(default)] + anchor_line: Option, + /// Maximum indentation depth to collect; `0` means unlimited. + #[serde(default = "defaults::max_levels")] + max_levels: usize, + /// Whether to include sibling blocks at the same indentation level. + #[serde(default = "defaults::include_siblings")] + include_siblings: bool, + /// Whether to include header lines above the anchor block. This made on a best effort basis. + #[serde(default = "defaults::include_header")] + include_header: bool, + /// Optional hard cap on returned lines; defaults to the global `limit`. + #[serde(default)] + max_lines: Option, +} + +#[derive(Clone, Debug)] +struct LineRecord { + number: usize, + raw: String, + display: String, + indent: usize, +} + +impl LineRecord { + fn trimmed(&self) -> &str { + self.raw.trim_start() + } + + fn is_blank(&self) -> bool { + self.trimmed().is_empty() + } + + fn is_comment(&self) -> bool { + COMMENT_PREFIXES + .iter() + .any(|prefix| self.raw.trim().starts_with(prefix)) + } +} + +#[async_trait] +impl ToolHandler for ReadFileHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { payload, .. } = 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)?; + + let ReadFileArgs { + file_path, + offset, + limit, + mode, + indentation, + } = args; + + 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(), + )); + } + + let path = PathBuf::from(&file_path); + if !path.is_absolute() { + return Err(FunctionCallError::RespondToModel( + "file_path must be an absolute path".to_string(), + )); + } + + let collected = match mode { + ReadMode::Slice => slice::read(&path, offset, limit).await?, + ReadMode::Indentation => { + let indentation = indentation.unwrap_or_default(); + indentation::read_block(&path, offset, limit, indentation).await? + } + }; + Ok(FunctionToolOutput::from_text( + collected.join("\n"), + Some(true), + )) + } +} + +mod slice { + use crate::function_tool::FunctionCallError; + use crate::tools::handlers::read_file::format_line; + use std::path::Path; + use tokio::fs::File; + use tokio::io::AsyncBufReadExt; + use tokio::io::BufReader; + + pub async fn read( + path: &Path, + offset: usize, + limit: usize, + ) -> Result, FunctionCallError> { + let file = File::open(path).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + let mut reader = BufReader::new(file); + let mut collected = Vec::new(); + let mut seen = 0usize; + let mut buffer = Vec::new(); + + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + if bytes_read == 0 { + break; + } + + if buffer.last() == Some(&b'\n') { + buffer.pop(); + if buffer.last() == Some(&b'\r') { + buffer.pop(); + } + } + + seen += 1; + + if seen < offset { + continue; + } + + if collected.len() == limit { + break; + } + + let formatted = format_line(&buffer); + collected.push(format!("L{seen}: {formatted}")); + + if collected.len() == limit { + break; + } + } + + if seen < offset { + return Err(FunctionCallError::RespondToModel( + "offset exceeds file length".to_string(), + )); + } + + Ok(collected) + } +} + +mod indentation { + use crate::function_tool::FunctionCallError; + use crate::tools::handlers::read_file::IndentationArgs; + use crate::tools::handlers::read_file::LineRecord; + use crate::tools::handlers::read_file::TAB_WIDTH; + use crate::tools::handlers::read_file::format_line; + use crate::tools::handlers::read_file::trim_empty_lines; + use std::collections::VecDeque; + use std::path::Path; + use tokio::fs::File; + use tokio::io::AsyncBufReadExt; + use tokio::io::BufReader; + + pub async fn read_block( + path: &Path, + offset: usize, + limit: usize, + options: IndentationArgs, + ) -> Result, FunctionCallError> { + let anchor_line = options.anchor_line.unwrap_or(offset); + if anchor_line == 0 { + return Err(FunctionCallError::RespondToModel( + "anchor_line must be a 1-indexed line number".to_string(), + )); + } + + let guard_limit = options.max_lines.unwrap_or(limit); + if guard_limit == 0 { + return Err(FunctionCallError::RespondToModel( + "max_lines must be greater than zero".to_string(), + )); + } + + let collected = collect_file_lines(path).await?; + if collected.is_empty() || anchor_line > collected.len() { + return Err(FunctionCallError::RespondToModel( + "anchor_line exceeds file length".to_string(), + )); + } + + let anchor_index = anchor_line - 1; + let effective_indents = compute_effective_indents(&collected); + let anchor_indent = effective_indents[anchor_index]; + + // Compute the min indent + let min_indent = if options.max_levels == 0 { + 0 + } else { + anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH) + }; + + // Cap requested lines by guard_limit and file length + let final_limit = limit.min(guard_limit).min(collected.len()); + + if final_limit == 1 { + return Ok(vec![format!( + "L{}: {}", + collected[anchor_index].number, collected[anchor_index].display + )]); + } + + // Cursors + let mut i: isize = anchor_index as isize - 1; // up (inclusive) + let mut j: usize = anchor_index + 1; // down (inclusive) + let mut i_counter_min_indent = 0; + let mut j_counter_min_indent = 0; + + let mut out = VecDeque::with_capacity(limit); + out.push_back(&collected[anchor_index]); + + while out.len() < final_limit { + let mut progressed = 0; + + // Up. + if i >= 0 { + let iu = i as usize; + if effective_indents[iu] >= min_indent { + out.push_front(&collected[iu]); + progressed += 1; + i -= 1; + + // We do not include the siblings (not applied to comments). + if effective_indents[iu] == min_indent && !options.include_siblings { + let allow_header_comment = + options.include_header && collected[iu].is_comment(); + let can_take_line = allow_header_comment || i_counter_min_indent == 0; + + if can_take_line { + i_counter_min_indent += 1; + } else { + // This line shouldn't have been taken. + out.pop_front(); + progressed -= 1; + i = -1; // consider using Option or a control flag instead of a sentinel + } + } + + // Short-cut. + if out.len() >= final_limit { + break; + } + } else { + // Stop moving up. + i = -1; + } + } + + // Down. + if j < collected.len() { + let ju = j; + if effective_indents[ju] >= min_indent { + out.push_back(&collected[ju]); + progressed += 1; + j += 1; + + // We do not include the siblings (applied to comments). + if effective_indents[ju] == min_indent && !options.include_siblings { + if j_counter_min_indent > 0 { + // This line shouldn't have been taken. + out.pop_back(); + progressed -= 1; + j = collected.len(); + } + j_counter_min_indent += 1; + } + } else { + // Stop moving down. + j = collected.len(); + } + } + + if progressed == 0 { + break; + } + } + + // Trim empty lines + trim_empty_lines(&mut out); + + Ok(out + .into_iter() + .map(|record| format!("L{}: {}", record.number, record.display)) + .collect()) + } + + async fn collect_file_lines(path: &Path) -> Result, FunctionCallError> { + let file = File::open(path).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut lines = Vec::new(); + let mut number = 0usize; + + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + if bytes_read == 0 { + break; + } + + if buffer.last() == Some(&b'\n') { + buffer.pop(); + if buffer.last() == Some(&b'\r') { + buffer.pop(); + } + } + + number += 1; + let raw = String::from_utf8_lossy(&buffer).into_owned(); + let indent = measure_indent(&raw); + let display = format_line(&buffer); + lines.push(LineRecord { + number, + raw, + display, + indent, + }); + } + + Ok(lines) + } + + fn compute_effective_indents(records: &[LineRecord]) -> Vec { + let mut effective = Vec::with_capacity(records.len()); + let mut previous_indent = 0usize; + for record in records { + if record.is_blank() { + effective.push(previous_indent); + } else { + previous_indent = record.indent; + effective.push(previous_indent); + } + } + effective + } + + fn measure_indent(line: &str) -> usize { + line.chars() + .take_while(|c| matches!(c, ' ' | '\t')) + .map(|c| if c == '\t' { TAB_WIDTH } else { 1 }) + .sum() + } +} + +fn format_line(bytes: &[u8]) -> String { + let decoded = String::from_utf8_lossy(bytes); + if decoded.len() > MAX_LINE_LENGTH { + take_bytes_at_char_boundary(&decoded, MAX_LINE_LENGTH).to_string() + } else { + decoded.into_owned() + } +} + +fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) { + while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) { + out.pop_front(); + } + while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) { + out.pop_back(); + } +} + +mod defaults { + use super::*; + + impl Default for IndentationArgs { + fn default() -> Self { + Self { + anchor_line: None, + max_levels: max_levels(), + include_siblings: include_siblings(), + include_header: include_header(), + max_lines: None, + } + } + } + + pub fn offset() -> usize { + 1 + } + + pub fn limit() -> usize { + 2000 + } + + pub fn max_levels() -> usize { + 0 + } + + pub fn include_siblings() -> bool { + false + } + + pub fn include_header() -> bool { + true + } +} + +#[cfg(test)] +#[path = "read_file_tests.rs"] +mod tests; 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 4d95a2c20..af293a5e7 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -10,15 +10,36 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; use codex_protocol::request_user_input::RequestUserInputArgs; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QuestionOptionsPolicy { + RequireOptions, + AllowFreeform, +} + +fn question_is_available(mode: ModeKind) -> bool { + mode.allows_request_user_input() || mode == ModeKind::Default +} + fn request_user_input_is_available(mode: ModeKind, default_mode_request_user_input: bool) -> bool { mode.allows_request_user_input() || (default_mode_request_user_input && mode == ModeKind::Default) } -fn format_allowed_modes(default_mode_request_user_input: bool) -> String { +fn tool_is_available( + tool_name: &str, + mode: ModeKind, + default_mode_request_user_input: bool, +) -> bool { + match tool_name { + "question" => question_is_available(mode), + _ => request_user_input_is_available(mode, default_mode_request_user_input), + } +} + +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(); @@ -30,6 +51,15 @@ 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}.") +} + pub(crate) fn request_user_input_unavailable_message( mode: ModeKind, default_mode_request_user_input: bool, @@ -44,13 +74,69 @@ pub(crate) fn request_user_input_unavailable_message( } } +pub(crate) 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")) + } +} + pub(crate) 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", + "Request user input for one to three short questions and wait for the response.", + default_mode_request_user_input, ) } +pub(crate) fn question_tool_description(default_mode_request_user_input: bool) -> String { + interactive_question_tool_description( + "question", + "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, + ) +} + +fn question_options_policy(tool_name: &str) -> QuestionOptionsPolicy { + match tool_name { + "question" => QuestionOptionsPolicy::AllowFreeform, + _ => QuestionOptionsPolicy::RequireOptions, + } +} + +fn normalize_question_args( + args: &mut RequestUserInputArgs, + options_policy: QuestionOptionsPolicy, +) -> Result<(), FunctionCallError> { + 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; + } + } + + if options_policy == QuestionOptionsPolicy::RequireOptions + && args + .questions + .iter() + .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)) + { + return Err(FunctionCallError::RespondToModel( + "request_user_input requires non-empty options for every question".to_string(), + )); + } + + Ok(()) +} + pub struct RequestUserInputHandler { pub default_mode_request_user_input: bool, } @@ -68,6 +154,7 @@ impl ToolHandler for RequestUserInputHandler { session, turn, call_id, + tool_name, payload, .. } = invocation; @@ -75,32 +162,23 @@ impl ToolHandler for RequestUserInputHandler { let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { - return Err(FunctionCallError::RespondToModel( - "request_user_input handler received unsupported payload".to_string(), - )); + return Err(FunctionCallError::RespondToModel(format!( + "{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" => 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 mut args: RequestUserInputArgs = parse_arguments(&arguments)?; - let missing_options = args - .questions - .iter() - .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)); - if missing_options { - return Err(FunctionCallError::RespondToModel( - "request_user_input requires non-empty options for every question".to_string(), - )); - } - for question in &mut args.questions { - question.is_other = true; - } + normalize_question_args(&mut args, question_options_policy(&tool_name))?; let response = session .request_user_input(turn.as_ref(), call_id, args) .await diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index f4df3c43c..ceb8b7da6 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -33,6 +33,20 @@ fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { ); } +#[test] +fn question_unavailable_messages_allow_default_mode() { + 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_tool_description_mentions_available_modes() { assert_eq!( @@ -44,3 +58,15 @@ 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 question_tool_description_mentions_available_modes() { + assert_eq!( + question_tool_description(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(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() + ); +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 3c10c3b84..70e0d2c9a 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -23,9 +23,10 @@ use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; -use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::question_tool_description; use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; @@ -167,39 +168,6 @@ fn send_input_output_schema() -> JsonValue { }) } -fn list_agents_output_schema() -> JsonValue { - json!({ - "type": "object", - "properties": { - "agents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "agent_name": { - "type": "string", - "description": "Canonical task name for the agent when available, otherwise the agent id." - }, - "agent_status": { - "description": "Last known status of the agent.", - "allOf": [agent_status_output_schema()] - }, - "last_task_message": { - "type": ["string", "null"], - "description": "Most recent user or inter-agent instruction received by the agent, when available." - } - }, - "required": ["agent_name", "agent_status", "last_task_message"], - "additionalProperties": false - }, - "description": "Live agents visible in the current root thread tree." - } - }, - "required": ["agents"], - "additionalProperties": false - }) -} - fn resume_agent_output_schema() -> JsonValue { json!({ "type": "object", @@ -211,7 +179,7 @@ fn resume_agent_output_schema() -> JsonValue { }) } -fn wait_output_schema_v1() -> JsonValue { +fn wait_output_schema() -> JsonValue { json!({ "type": "object", "properties": { @@ -230,24 +198,6 @@ fn wait_output_schema_v1() -> JsonValue { }) } -fn wait_output_schema_v2() -> JsonValue { - json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Brief wait summary without the agent's final content." - }, - "timed_out": { - "type": "boolean", - "description": "Whether the wait call returned due to timeout before any agent reached a final status." - } - }, - "required": ["message", "timed_out"], - "additionalProperties": false - }) -} - fn close_agent_output_schema() -> JsonValue { json!({ "type": "object", @@ -388,11 +338,8 @@ impl ToolsConfig { let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); - let include_search_tool = - model_info.supports_search_tool && features.enabled(Feature::ToolSearch); - let include_tool_suggest = features.enabled(Feature::ToolSuggest) - && features.enabled(Feature::Apps) - && features.enabled(Feature::Plugins); + let include_search_tool = model_info.supports_search_tool; + let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest); let include_original_image_detail = can_request_original_image_detail(features, model_info); let include_artifact_tools = features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime(); @@ -1186,6 +1133,15 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { ), }, ), + ( + "cwd".to_string(), + JsonSchema::String { + description: Some( + "Optional working directory for the new agent. Defaults to inheriting the parent cwd. Relative paths resolve against the parent cwd." + .to_string(), + ), + }, + ), ]); properties.insert( "task_name".to_string(), @@ -1451,80 +1407,6 @@ fn create_send_input_tool() -> ToolSpec { }) } -fn create_send_message_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "target".to_string(), - JsonSchema::String { - description: Some( - "Agent id or canonical task name to message (from spawn_agent).".to_string(), - ), - }, - ), - ("items".to_string(), create_collab_input_items_schema()), - ( - "interrupt".to_string(), - JsonSchema::Boolean { - description: Some( - "When true, stop the agent's current task and handle this immediately. When false (default), queue this message." - .to_string(), - ), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "send_message".to_string(), - description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["target".to_string(), "items".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: Some(send_input_output_schema()), - }) -} - -fn create_assign_task_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "target".to_string(), - JsonSchema::String { - description: Some( - "Agent id or canonical task name to message (from spawn_agent).".to_string(), - ), - }, - ), - ("items".to_string(), create_collab_input_items_schema()), - ( - "interrupt".to_string(), - JsonSchema::Boolean { - description: Some( - "When true, stop the agent's current task and handle this immediately. When false (default), queue this message." - .to_string(), - ), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "assign_task".to_string(), - description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["target".to_string(), "items".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: Some(send_input_output_schema()), - }) -} - fn create_resume_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -1550,7 +1432,7 @@ fn create_resume_agent_tool() -> ToolSpec { }) } -fn wait_agent_tool_parameters() -> JsonSchema { +fn create_wait_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( "targets".to_string(), @@ -1571,66 +1453,111 @@ fn wait_agent_tool_parameters() -> JsonSchema { }, ); - JsonSchema::Object { - properties, - required: Some(vec!["targets".to_string()]), - additional_properties: Some(false.into()), - } -} - -fn create_wait_agent_tool_v1() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "wait_agent".to_string(), description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." .to_string(), strict: false, defer_loading: None, - parameters: wait_agent_tool_parameters(), - output_schema: Some(wait_output_schema_v1()), + parameters: JsonSchema::Object { + properties, + required: Some(vec!["targets".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(wait_output_schema()), }) } -fn create_wait_agent_tool_v2() -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { - name: "wait_agent".to_string(), - description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline." - .to_string(), - strict: false, - defer_loading: None, - parameters: wait_agent_tool_parameters(), - output_schema: Some(wait_output_schema_v2()), - }) -} +fn create_request_user_input_tool( + collaboration_modes_config: CollaborationModesConfig, +) -> ToolSpec { + let mut option_props = BTreeMap::new(); + option_props.insert( + "label".to_string(), + JsonSchema::String { + description: Some("User-facing label (1-5 words).".to_string()), + }, + ); + option_props.insert( + "description".to_string(), + JsonSchema::String { + description: Some( + "One short sentence explaining impact/tradeoff if selected.".to_string(), + ), + }, + ); -fn create_list_agents_tool() -> ToolSpec { - let properties = BTreeMap::from([( - "path_prefix".to_string(), + 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(), + ), + items: Box::new(JsonSchema::Object { + properties: option_props, + required: Some(vec!["label".to_string(), "description".to_string()]), + additional_properties: Some(false.into()), + }), + }; + + let mut question_props = BTreeMap::new(); + question_props.insert( + "id".to_string(), + JsonSchema::String { + description: Some("Stable identifier for mapping answers (snake_case).".to_string()), + }, + ); + question_props.insert( + "header".to_string(), JsonSchema::String { description: Some( - "Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets." - .to_string(), + "Short header label shown in the UI (12 or fewer chars).".to_string(), ), }, - )]); + ); + question_props.insert( + "question".to_string(), + JsonSchema::String { + description: Some("Single-sentence prompt shown to the user.".to_string()), + }, + ); + question_props.insert("options".to_string(), options_schema); + + let questions_schema = JsonSchema::Array { + description: Some( + "Questions to show the user. Use as many as needed for the form.".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(), + ]), + additional_properties: Some(false.into()), + }), + }; + + let mut properties = BTreeMap::new(); + properties.insert("questions".to_string(), questions_schema); ToolSpec::Function(ResponsesApiTool { - name: "list_agents".to_string(), - description: "List live agents in the current root thread tree. Optionally filter by task-path prefix." - .to_string(), + name: "request_user_input".to_string(), + description: request_user_input_tool_description( + collaboration_modes_config.default_mode_request_user_input, + ), strict: false, defer_loading: None, parameters: JsonSchema::Object { properties, - required: None, + required: Some(vec!["questions".to_string()]), additional_properties: Some(false.into()), }, - output_schema: Some(list_agents_output_schema()), + output_schema: None, }) } -fn create_request_user_input_tool( - collaboration_modes_config: CollaborationModesConfig, -) -> ToolSpec { +fn create_question_tool(collaboration_modes_config: CollaborationModesConfig) -> ToolSpec { let mut option_props = BTreeMap::new(); option_props.insert( "label".to_string(), @@ -1649,7 +1576,7 @@ fn create_request_user_input_tool( 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." + "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 { @@ -1677,20 +1604,22 @@ fn create_request_user_input_tool( question_props.insert( "question".to_string(), JsonSchema::String { - description: Some("Single-sentence prompt shown to the user.".to_string()), + description: Some("Prompt shown to the user for this field.".to_string()), }, ); question_props.insert("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( + "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: question_props, required: Some(vec![ "id".to_string(), "header".to_string(), "question".to_string(), - "options".to_string(), ]), additional_properties: Some(false.into()), }), @@ -1700,8 +1629,8 @@ fn create_request_user_input_tool( properties.insert("questions".to_string(), questions_schema); ToolSpec::Function(ResponsesApiTool { - name: "request_user_input".to_string(), - description: request_user_input_tool_description( + name: "question".to_string(), + description: question_tool_description( collaboration_modes_config.default_mode_request_user_input, ), strict: false, @@ -1769,6 +1698,234 @@ fn create_close_agent_tool() -> ToolSpec { }) } +fn create_loop_tool() -> ToolSpec { + let trigger_schema = JsonSchema::Object { + properties: BTreeMap::from([ + ( + "kind".to_string(), + JsonSchema::String { + description: Some( + "Trigger type. Use `timer`, `before_turn`, or `after_turn`." + .to_string(), + ), + }, + ), + ( + "schedule".to_string(), + JsonSchema::String { + description: Some( + "Timer schedule such as `10m` or a cron string. Required when kind is `timer`." + .to_string(), + ), + }, + ), + ]), + required: Some(vec!["kind".to_string()]), + additional_properties: Some(false.into()), + }; + + let create_schema = JsonSchema::Object { + properties: BTreeMap::from([ + ( + "id".to_string(), + JsonSchema::String { + description: Some( + "Loop id. Required for persistent loops. Omit for embed or ephemeral loops." + .to_string(), + ), + }, + ), + ( + "prompt".to_string(), + JsonSchema::String { + description: Some("Loop prompt to execute.".to_string()), + }, + ), + ( + "action".to_string(), + JsonSchema::String { + description: Some( + "Optional extra action text appended when the loop emits a user message." + .to_string(), + ), + }, + ), + ( + "context_mode".to_string(), + JsonSchema::String { + description: Some( + "Loop context mode. Use `embed`, `ephemeral`, or `persistent`." + .to_string(), + ), + }, + ), + ( + "response_mode".to_string(), + JsonSchema::String { + description: Some( + "How loop results are delivered. Use `assistant` or `user`." + .to_string(), + ), + }, + ), + ( + "security_mode".to_string(), + JsonSchema::String { + description: Some( + "Loop execution security. Use `inherited` or `specified_directory`." + .to_string(), + ), + }, + ), + ( + "cwd".to_string(), + JsonSchema::String { + description: Some( + "Optional working directory for the loop. Relative paths resolve against the current workspace." + .to_string(), + ), + }, + ), + ( + "writable_roots".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some( + "Writable directories when security_mode is `specified_directory`. Relative paths resolve against the current workspace." + .to_string(), + ), + }, + ), + ("trigger".to_string(), trigger_schema.clone()), + ]), + required: Some(vec![ + "prompt".to_string(), + "context_mode".to_string(), + "response_mode".to_string(), + "security_mode".to_string(), + "trigger".to_string(), + ]), + additional_properties: Some(false.into()), + }; + + let update_schema = JsonSchema::Object { + properties: BTreeMap::from([ + ( + "prompt".to_string(), + JsonSchema::String { + description: Some("Optional new loop prompt.".to_string()), + }, + ), + ( + "action".to_string(), + JsonSchema::String { + description: Some( + "Optional new action text. Pass null to clear the current action." + .to_string(), + ), + }, + ), + ( + "context_mode".to_string(), + JsonSchema::String { + description: Some( + "Optional new context mode. Use `embed`, `ephemeral`, or `persistent`." + .to_string(), + ), + }, + ), + ( + "response_mode".to_string(), + JsonSchema::String { + description: Some( + "Optional new response mode. Use `assistant` or `user`." + .to_string(), + ), + }, + ), + ( + "security_mode".to_string(), + JsonSchema::String { + description: Some( + "Optional new security mode. Use `inherited` or `specified_directory`." + .to_string(), + ), + }, + ), + ( + "cwd".to_string(), + JsonSchema::String { + description: Some( + "Optional new working directory. Pass null to clear the current override." + .to_string(), + ), + }, + ), + ( + "writable_roots".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some( + "Optional writable directories replacement when using `specified_directory`." + .to_string(), + ), + }, + ), + ( + "enabled".to_string(), + JsonSchema::Boolean { + description: Some("Optional enabled flag.".to_string()), + }, + ), + ( + "trigger_bindings".to_string(), + JsonSchema::Array { + items: Box::new(trigger_schema), + description: Some( + "Optional full replacement for the loop trigger bindings." + .to_string(), + ), + }, + ), + ]), + required: None, + additional_properties: Some(false.into()), + }; + + ToolSpec::Function(ResponsesApiTool { + name: "loop".to_string(), + description: "Manage workspace-local loop agents. Use this tool to create, list, inspect, update, or delete loops in the current workspace. The tool writes shared loop metadata so TUI Loop Manager and other loop services stay in sync.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([ + ( + "op".to_string(), + JsonSchema::String { + description: Some( + "Loop operation to perform. Use `create`, `list`, `info`, `update`, or `delete`." + .to_string(), + ), + }, + ), + ( + "id".to_string(), + JsonSchema::String { + description: Some( + "Loop id for `info`, `update`, or `delete`.".to_string(), + ), + }, + ), + ("create".to_string(), create_schema), + ("update".to_string(), update_schema), + ]), + required: Some(vec!["op".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) +} + fn create_test_sync_tool() -> ToolSpec { let barrier_properties = BTreeMap::from([ ( @@ -1838,6 +1995,59 @@ fn create_test_sync_tool() -> ToolSpec { }) } +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, + }) +} + fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2045,6 +2255,111 @@ fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String { } } +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, + }) +} + fn create_list_dir_tool() -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2592,12 +2907,15 @@ 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::LoopToolHandler; 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; @@ -2612,11 +2930,6 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; - use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2; - use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; - use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; - use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; - use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use std::sync::Arc; let mut builder = ToolRegistryBuilder::new(); @@ -2793,6 +3106,14 @@ pub(crate) fn build_specs_with_discoverable_tools( } if config.request_user_input { + push_tool_spec( + &mut builder, + create_question_tool(CollaborationModesConfig { + default_mode_request_user_input: config.default_mode_request_user_input, + }), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); push_tool_spec( &mut builder, create_request_user_input_tool(CollaborationModesConfig { @@ -2801,6 +3122,7 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); + builder.register_handler("question", request_user_input_handler.clone()); builder.register_handler("request_user_input", request_user_input_handler); } @@ -2868,6 +3190,34 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("apply_patch", apply_patch_handler); } + if config + .experimental_supported_tools + .contains(&"grep_files".to_string()) + { + let grep_files_handler = Arc::new(GrepFilesHandler); + push_tool_spec( + &mut builder, + create_grep_files_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + builder.register_handler("grep_files", grep_files_handler); + } + + if config + .experimental_supported_tools + .contains(&"read_file".to_string()) + { + let read_file_handler = Arc::new(ReadFileHandler); + push_tool_spec( + &mut builder, + create_read_file_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + builder.register_handler("read_file", read_file_handler); + } + if config .experimental_supported_tools .iter() @@ -2967,6 +3317,12 @@ pub(crate) fn build_specs_with_discoverable_tools( } if config.collab_tools { + push_tool_spec( + &mut builder, + create_loop_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); push_tool_spec( &mut builder, create_spawn_agent_tool(config), @@ -2975,22 +3331,10 @@ pub(crate) fn build_specs_with_discoverable_tools( ); push_tool_spec( &mut builder, - if config.multi_agent_v2 { - create_send_message_tool() - } else { - create_send_input_tool() - }, + create_send_input_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - if config.multi_agent_v2 { - push_tool_spec( - &mut builder, - create_assign_task_tool(), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); - } if !config.multi_agent_v2 { push_tool_spec( &mut builder, @@ -3002,11 +3346,7 @@ pub(crate) fn build_specs_with_discoverable_tools( } push_tool_spec( &mut builder, - if config.multi_agent_v2 { - create_wait_agent_tool_v2() - } else { - create_wait_agent_tool_v1() - }, + create_wait_agent_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); @@ -3016,23 +3356,10 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - if config.multi_agent_v2 { - push_tool_spec( - &mut builder, - create_list_agents_tool(), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); - builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2)); - builder.register_handler("send_message", Arc::new(SendMessageHandlerV2)); - builder.register_handler("assign_task", Arc::new(AssignTaskHandlerV2)); - builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2)); - builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2)); - } else { - builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); - builder.register_handler("send_input", Arc::new(SendInputHandler)); - builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); - } + builder.register_handler("loop", Arc::new(LoopToolHandler)); + builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); + builder.register_handler("send_input", Arc::new(SendInputHandler)); + builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); builder.register_handler("close_agent", Arc::new(CloseAgentHandler)); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 6089e8bf7..503f434a9 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -457,6 +457,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_exec_command_tool(true, false), create_write_stdin_tool(), PLAN_TOOL.clone(), + create_question_tool(CollaborationModesConfig::default()), create_request_user_input_tool(CollaborationModesConfig::default()), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { @@ -467,13 +468,10 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { search_content_types: None, }, create_view_image_tool(config.can_request_original_image_detail), + create_loop_tool(), create_spawn_agent_tool(&config), create_send_input_tool(), - if config.multi_agent_v2 { - create_wait_agent_tool_v2() - } else { - create_wait_agent_tool_v1() - }, + create_wait_agent_tool(), create_close_agent_tool(), ] { expected.insert(tool_name(&spec).to_string(), spec); @@ -522,7 +520,13 @@ fn test_build_specs_collab_tools_enabled() { let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert_contains_tool_names( &tools, - &["spawn_agent", "send_input", "wait_agent", "close_agent"], + &[ + "loop", + "spawn_agent", + "send_input", + "wait_agent", + "close_agent", + ], ); assert_lacks_tool_name(&tools, "spawn_agents_on_csv"); assert_lacks_tool_name(&tools, "list_agents"); @@ -575,6 +579,7 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { else { panic!("spawn_agent should use object params"); }; + assert!(properties.contains_key("cwd")); assert!(properties.contains_key("task_name")); assert_eq!(required.as_ref(), None); let output_schema = output_schema @@ -701,6 +706,7 @@ fn test_build_specs_enable_fanout_enables_agent_jobs_and_collab_tools() { assert_contains_tool_names( &tools, &[ + "loop", "spawn_agent", "send_input", "wait_agent", @@ -830,6 +836,7 @@ fn test_build_specs_agent_job_worker_tools_enabled() { "report_agent_job_result", ], ); + assert_lacks_tool_name(&tools, "question"); assert_lacks_tool_name(&tools, "request_user_input"); } @@ -849,6 +856,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let question_tool = find_tool(&tools, "question"); + assert_eq!( + question_tool.spec, + create_question_tool(CollaborationModesConfig::default()) + ); let request_user_input_tool = find_tool(&tools, "request_user_input"); assert_eq!( request_user_input_tool.spec, @@ -867,6 +879,13 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let question_tool = find_tool(&tools, "question"); + assert_eq!( + question_tool.spec, + create_question_tool(CollaborationModesConfig { + default_mode_request_user_input: true, + }) + ); let request_user_input_tool = find_tool(&tools, "request_user_input"); assert_eq!( request_user_input_tool.spec, @@ -1353,6 +1372,7 @@ fn test_build_specs_gpt5_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1376,6 +1396,7 @@ fn test_build_specs_gpt51_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1401,6 +1422,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", @@ -1426,6 +1448,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", @@ -1449,6 +1472,7 @@ fn test_gpt_5_1_codex_max_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1472,6 +1496,7 @@ fn test_codex_5_1_mini_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1495,6 +1520,7 @@ fn test_gpt_5_defaults() { "shell", &[ "update_plan", + "question", "request_user_input", "web_search", "view_image", @@ -1517,6 +1543,7 @@ fn test_gpt_5_1_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1542,6 +1569,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/docs/biweekly_backplan_2026_05_15.drawio b/codex-rs/docs/biweekly_backplan_2026_05_15.drawio new file mode 100644 index 000000000..7eaffdf72 --- /dev/null +++ b/codex-rs/docs/biweekly_backplan_2026_05_15.drawio @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/codex-rs/docs/clawbot-message-actions-design.md b/codex-rs/docs/clawbot-message-actions-design.md new file mode 100644 index 000000000..e3aab3bbe --- /dev/null +++ b/codex-rs/docs/clawbot-message-actions-design.md @@ -0,0 +1,126 @@ +# Clawbot Message Actions Design + +## Scope + +This design covers: + +- Feishu auto-ack reactions for inbound clawbot messages +- plain text replies for clawbot-originated turns +- a persisted clawbot turn mode that can disable interactive prompts + +This design does not yet cover: + +- assistant-controlled reaction replies +- persistent reaction receipts across process restarts +- transcript sanitization beyond the current clawbot info-cell annotations + +## Architecture + +```text +Feishu inbound message + -> codex-clawbot provider event + -> unread cache + -> drain into bound thread + -> auto-ack reaction + -> submit normal clawbot turn + -> record turn_id <-> turn mode + -> TurnComplete / Error + -> forward plain text reply +``` + +## Data Model + +### `codex-clawbot` + +- `ClawbotTurnMode` + - `interactive` + - `non_interactive` +- `ProviderMessageRef` + - `provider` + - `session_id` + - `message_id` +- `ProviderOutboundReaction` + - `target` + - `emoji_type` +- `ProviderOutboundAction` + - `Text` + - `AddReaction` + +### TUI in-memory turn tracking + +Per thread, keep a FIFO of pending clawbot turns: + +- `turn_id` +- `thread_id` +- `turn_mode` + +The queue is in-memory because the associated turn is also in-flight process +state. If the process dies, the turn is already lost. + +## Reply Handling + +Clawbot-originated turns submit a normal user turn with no clawbot-specific +`final_output_json_schema`. + +Reply handling stays intentionally simple: + +- if the final assistant message is a non-empty string, forward it as text +- do not parse or recover legacy structured reply envelopes +- do not send assistant-controlled reactions + +## Non-Interactive Turn Mode + +### Submission layer + +For `non_interactive` clawbot turns, use: + +- `AskForApproval::Granular` +- all granular flags set to `false` + +This prevents approval-driven UI from surfacing for: + +- sandbox approval +- execpolicy prompt rules +- skill approval +- `request_permissions` +- MCP elicitations + +### Event layer + +`request_user_input` is not blocked by approval policy, so the TUI must catch it +for clawbot-originated turns and auto-answer with an empty response. + +As a defensive fallback, `request_permissions` can also be auto-resolved with an +empty permission grant if it still appears. + +## UI Surface + +Clawbot control panel gains a persisted turn-mode option: + +- `interactive` +- `non-interactive` + +The transcript keeps using info cells: + +- `Feishu message` +- `Feishu auto reaction` +- `Clawbot auto response` + +## Feishu Mapping + +Feishu implementation extends the existing text send path with: + +- add reaction for the initial auto-ack + +Auto-ack uses provider `emoji_type = "TONGUE"` and is rendered in the TUI as `😛`. + +## Todo Sequence + +1. Keep the provider message reference for inbound auto-ack +2. Keep Feishu add-reaction support for the initial auto-ack +3. Add persisted clawbot turn mode to workspace config +4. Add control-panel toggle for turn mode +5. Track clawbot-originated turn ids in the TUI +6. Auto-ack inbound Feishu messages when draining into a thread +7. Forward plain text replies on turn completion +8. Auto-answer `request_user_input` for non-interactive clawbot turns diff --git a/codex-rs/docs/clawbot-message-actions-proposal.md b/codex-rs/docs/clawbot-message-actions-proposal.md new file mode 100644 index 000000000..9a683d5ba --- /dev/null +++ b/codex-rs/docs/clawbot-message-actions-proposal.md @@ -0,0 +1,63 @@ +# Clawbot Message Actions Proposal + +## Situation + +`codex-clawbot` currently treats outbound IM delivery as a text-only path: + +- inbound Feishu messages are normalized into `session_id + message_id + text` +- the text is submitted into a bound Codex thread +- the final assistant text is forwarded back to the bound session + +That model is sufficient for plain text bridging, but it still needs one small +operator-facing acknowledgement: + +1. inbound messages should receive an immediate automatic reaction to confirm + receipt + +There is a second operator requirement: + +- when a turn originated from clawbot, the operator must be able to force the + turn into a non-interactive mode where question / permission / approval + prompts do not block the remote session + +## Task + +Keep clawbot simple while making bound external turns safer and more legible: + +- react once to the exact inbound provider message when it is accepted +- forward only plain text replies to the correct session +- auto-dismiss confirmation-driven interactions for remote-safe turns + +## First Principles + +The core abstraction is still "external text bridge with a lightweight ack". + +That yields two design rules: + +1. only the initial auto-ack should use a provider-specific message action +2. non-interactive clawbot turns must be enforced both at submission time and + at interactive-event time + +## Proposal + +For clawbot-originated turns, the TUI should: + +- submit a normal text turn without a clawbot-specific output schema +- keep the pending turn tracking needed for non-interactive handling +- add an immediate automatic `😛` reaction when the inbound message is drained +- forward only the final plain text response back to the bound session + +For non-interactive clawbot turns, the TUI should: + +- submit the turn with a granular approval policy that rejects permission and + approval prompts +- auto-answer `request_user_input` with an empty response + +## Result + +This phase keeps the fork on the KISS path: + +- one provider-specific message action for the initial Feishu auto-ack +- plain text forwarding for all later assistant replies +- a future phase can still promote richer Feishu actions into dedicated tools + without reintroducing reply-envelope parsing into the hot path diff --git a/codex-rs/docs/clawbot-mvp-design.md b/codex-rs/docs/clawbot-mvp-design.md new file mode 100644 index 000000000..112820942 --- /dev/null +++ b/codex-rs/docs/clawbot-mvp-design.md @@ -0,0 +1,573 @@ +# Clawbot MVP Design + +## Proposal + +Add a new fork-owned crate, `codex-clawbot`, to host the runtime, provider +adapters, workspace state, and thread-bridge logic for IM-driven Codex +conversations. + +The first supported provider is Feishu private chat. The first supported host +surface is `codex-rs/tui` only. + +This crate should not be folded into `codex-ext`. + +Reason: + +- `codex-ext` is currently the fork-owned extension data/model layer. +- Clawbot is a live runtime with network connections, reconnection, queueing, + workspace-local persistence, and TUI integration. +- Mixing those concerns would make both crates harder to evolve and test. + +`codex-clawbot` should own: + +- provider-agnostic session and binding models +- Feishu gateway runtime +- workspace-local state under `.codex/clawbot/` +- inbound/outbound queue orchestration +- thread binding and message routing decisions + +`codex-ext` can stay focused on long-lived extension-facing models. + +## Situation + +Current repository capabilities relevant to this MVP: + +- `codex-rs/tui` already has a fork-owned `Ctrl-P` control panel. +- The fork already manages workspace-level operational state and fork-owned UI + affordances. +- Thread operations already exist in TUI, but there is no IM/provider bridge. +- The repository includes a standalone Python Feishu bot reference under + `feishu_bot/`, with a gateway/worker split and a Feishu WS adapter. + +User-confirmed product constraints: + +- MVP host surface: local `tui` only +- MVP provider: Feishu only +- IM mode: private chat only +- one IM session binds to one Codex thread +- binding target for MVP: current thread only +- unbound sessions cache unread messages until manually connected +- after binding, cached unread messages are flushed into the thread in order +- only final assistant output is forwarded to Feishu +- failures should return an error message to Feishu +- no approval flow; assume full permission +- local and remote inputs can both target the same thread, but must queue +- local interrupt actions may cancel the running turn +- persistence should live under `workspace/.codex` +- restart behavior only needs to resume bindings and accept new messages; it + does not need to backfill missed history + +## Task + +Build a minimally correct clawbot runtime that: + +- discovers Feishu private-chat sessions +- lets the operator manually connect a session to the current thread +- flushes cached unread messages into that thread in order +- automatically forwards later final answers from that thread back to the bound + Feishu session +- survives Feishu WS reconnects and process restarts without losing the binding + model + +## First Principles + +The core system is not "a new control panel menu item". + +The core system is: + +- an external session source +- a persisted mapping from external session to Codex thread +- a serialized queue that turns external messages into user turns +- a reverse path that turns final assistant output into provider replies + +The control panel is only the operator surface for that runtime. + +That leads to two architectural decisions: + +1. The source of truth must live in a runtime/store layer, not in TUI popup + state. +2. Provider-specific code must be isolated behind a small adapter boundary so + future Slack/Weixin support does not rewrite the thread-binding semantics. + +## Why `codex-clawbot` + +Recommended new workspace member: + +- `codex-rs/clawbot` + +Recommended crate name: + +- `codex-clawbot` + +Why a separate crate: + +- keeps Feishu SDK coupling out of `codex-tui` +- keeps runtime/network code out of `codex-ext` +- gives the fork a clear place for provider adapters and binding state +- allows unit testing the runtime and queue model without TUI harnesses +- keeps future provider growth additive + +## Feishu Reference Mapping + +The Python reference in `feishu_bot/` is useful mainly for transport and +runtime structure, not for direct feature parity. + +Reference takeaways: + +- `feishu_bot/design.md` separates transport from business logic +- `feishu_bot/src/feishu_bot/runtime/gateway.py` owns the single WS connection + and converts raw SDK callbacks into normalized payloads +- the Python design forwards events sequentially into a worker + +Rust MVP mapping: + +- keep the "normalize provider events early" idea +- keep a single Feishu WS owner inside `codex-clawbot` +- replace the Python worker subprocess with an in-process queue/dispatcher +- replace command routing with thread binding + turn submission + +What we should not copy: + +- no gateway/worker split for MVP +- no command parser +- no card workflows +- no mention gating requirement for private chat + +## High-Level Architecture + +```text +codex-tui + -> control panel / clawbot panel + -> current thread context + +codex-clawbot + -> runtime + -> provider::feishu + -> store + -> binding registry + -> thread bridge + -> outbound forwarder + +workspace/.codex/clawbot + -> config + -> discovered sessions + -> bindings + -> unread cache + -> runtime state +``` + +### Integration Boundary + +`codex-tui` should depend on `codex-clawbot` for: + +- session list/state snapshots +- connect/disconnect actions +- cached unread counts +- runtime status +- notifications back into TUI when session state changes + +`codex-clawbot` should not depend on popup-specific TUI types. + +The integration boundary should use plain Rust structs and events. + +## Module Layout + +Recommended initial layout: + +```text +codex-rs/clawbot/ +├── Cargo.toml +└── src/ + ├── lib.rs + ├── config.rs + ├── model.rs + ├── store.rs + ├── runtime.rs + ├── events.rs + ├── binding.rs + ├── queue.rs + ├── bridge.rs + ├── provider/ + │ ├── mod.rs + │ └── feishu.rs + └── tests/ +``` + +Suggested responsibilities: + +- `config.rs` + Loads workspace-local clawbot config from `.codex/clawbot/config.toml` or + similar. +- `model.rs` + Shared data structures: provider id, session id, binding id, unread message, + runtime status. +- `store.rs` + Persistence for discovered sessions, bindings, unread cache, and restart + metadata. +- `runtime.rs` + Top-level runtime lifecycle, startup, reconnect, and event fan-in/fan-out. +- `events.rs` + Internal event enums flowing between provider, queue, bridge, and TUI. +- `binding.rs` + Session-thread mapping logic and invariants. +- `queue.rs` + Per-thread FIFO queue, drain policy, and interrupt coordination. +- `bridge.rs` + Converts provider messages into thread submissions and final assistant output + into provider replies. +- `provider/feishu.rs` + Feishu-specific WS client, message normalization, and outbound message send. + +## Persistence Model + +All clawbot state should live under: + +```text +/.codex/clawbot/ +``` + +Recommended files: + +```text +.codex/clawbot/ +├── config.toml +├── sessions.json +├── bindings.json +├── unread_messages.jsonl +└── runtime.json +``` + +Suggested contents: + +- `config.toml` + provider credentials and runtime flags +- `sessions.json` + discovered provider sessions and connection metadata +- `bindings.json` + persisted `session -> thread` mapping +- `unread_messages.jsonl` + cached inbound text messages for unbound sessions +- `runtime.json` + last-known runtime metadata useful for UI recovery + +MVP persistence rules: + +- bindings must survive restart +- discovered sessions may survive restart best-effort +- unread cache must survive restart +- reconnect only needs to accept new messages after startup +- no history backfill is required + +## Core Data Model + +Recommended normalized entities: + +### ProviderSession + +- `provider`: `"feishu"` +- `session_id` +- `display_name` +- `status` + values: `discovered`, `connected`, `disconnected`, `error` +- `unread_count` +- `last_message_at` +- `last_error` + +For Feishu private chat, `session_id` should be stable and provider-owned. In +practice this is likely the chat id. + +### SessionBinding + +- `provider` +- `session_id` +- `thread_id` +- `bound_at` +- `state` + values: `active`, `paused`, `error` + +MVP invariant: + +- one session maps to at most one thread +- one binding routes automatically once active + +### CachedUnreadMessage + +- `provider` +- `session_id` +- `message_id` +- `text` +- `received_at` + +MVP message payload is intentionally minimal: + +- text only +- no attachment support +- no quoted reply reconstruction +- no sender identity injected into the user message body + +## Thread Binding Semantics + +MVP binding rule: + +- the operator selects a discovered Feishu private-chat session +- the operator chooses `Connect To Current Thread` +- the current thread becomes the routing target for that session + +After binding: + +- all cached unread messages for that session are flushed into the queue in + order +- all future inbound messages for that session become queued user turns for the + bound thread +- final assistant output from that thread is forwarded to that same session + +This is a persistent binding model, not a global "active session redirect" +toggle. + +That is the simplest model that satisfies the user requirement: + +- "one Feishu session corresponds to one Codex thread" +- "current model input/output is redirected" + +In implementation terms, the redirect is achieved by the binding itself. + +## Queue and Turn Model + +We need one serialized queue per bound thread. + +Sources of work: + +- flushed cached unread messages +- new inbound Feishu messages +- optional local TUI submissions targeting the same thread + +Queue rules: + +- enqueue inbound provider messages in arrival order +- if a thread is idle, start draining immediately +- if a turn is running, keep later messages queued +- only one queued provider message may actively drive a turn at a time +- local interrupt actions may cancel the active turn +- once a turn finishes, dequeue the next message + +MVP simplification: + +- remote input always uses normal user turns +- no special "steer" path +- no streaming output forwarding +- only final output or failure is forwarded + +## Message Transformation + +Inbound Feishu message to Codex: + +- extract plain text only +- create one queued user turn with that text + +Outbound Codex to Feishu: + +- wait for final assistant message +- send final text reply to bound session + +Failure path: + +- if turn submission fails, send a provider error reply +- if the model/tool run ends in failure, send a provider error reply +- if provider send fails, mark session/binding status as error in runtime state + +Recommended error text shape: + +- short, operator-readable, not raw stack traces +- include enough detail to distinguish user error, provider send failure, and + Codex runtime failure + +## Feishu Adapter Scope + +MVP Feishu adapter responsibilities: + +- open and maintain WS connection +- normalize private-chat message events into `ProviderEvent::InboundText` +- expose connection status to runtime/UI +- send plain text outbound replies + +MVP Feishu adapter non-goals: + +- cards +- file/image handling +- group chats +- callbacks/actions +- history sync +- mention parsing + +The Python reference already shows the right normalization point: transport +callbacks should be converted into internal payloads as early as possible. + +## TUI Surface + +Add a new control-panel item in `codex-rs/tui`: + +- `Clawbot` + +Suggested initial panel content: + +- runtime connection status +- list of discovered Feishu private-chat sessions +- unread count per session +- current binding status +- last error if present + +Suggested MVP actions: + +- `Connect To Current Thread` +- `Disconnect` +- `Flush Cached Messages` +- `Retry Connection` + +Suggested selection subtitle examples: + +- unbound session: + `Feishu private chat with 3 unread messages waiting for connection.` +- bound session: + `Bound to the current thread and routing final answers automatically.` +- error session: + `Last send or runtime error needs operator attention.` + +## Why Not Route Through app-server + +For this MVP, do not build the first version on top of an internal +app-server-websocket client. + +Reasons: + +- local `tui` is the only confirmed surface +- app-server websocket transport is documented as experimental / unsupported +- adding an extra RPC hop increases complexity before we have a stable binding + model +- the real problem here is queueing and thread binding, not remote transport + +If we later need a remote clawbot service, we can move the bridge boundary to +app-server after the local runtime semantics are proven. + +## Integration Points in `codex-tui` + +Expected host integration areas: + +- control panel item registration +- session list popup construction +- app event additions for clawbot actions +- notification hooks when a thread finishes and final assistant output is ready +- lifecycle startup/shutdown hooks for the workspace runtime + +Keep TUI integration thin: + +- TUI dispatches operator intents +- runtime owns session state and routing + +## Testing Plan + +Unit tests in `codex-clawbot`: + +- binding invariants +- unread cache flush order +- queue drain order +- reconnect status transitions +- restart recovery from persisted bindings +- provider error to runtime state mapping + +TUI tests in `codex-tui`: + +- control panel includes clawbot entry +- clawbot session list snapshots +- session status rendering snapshots +- connect/disconnect action wiring + +Mock/provider tests: + +- fake Feishu inbound text event normalization +- outbound final reply formatting +- duplicate message id handling if we later choose to dedupe + +MVP note: + +- the current user requirement does not need strong dedupe semantics across + restart/backfill, so duplicate-prevention can stay local to a running process + in phase 1 + +## Implementation Phases + +### Phase 1: Crate and Models + +- add `codex-rs/clawbot` +- register `codex-clawbot` in workspace +- define config/model/store/runtime skeleton +- add provider abstraction with Feishu placeholder + +### Phase 2: Workspace State and Runtime + +- implement workspace-local persistence under `.codex/clawbot` +- implement runtime startup/shutdown and state snapshots +- surface runtime status for TUI + +### Phase 3: Feishu Private Chat Adapter + +- implement Feishu WS connection +- normalize inbound private-chat text messages +- implement plain-text outbound replies +- implement reconnect status transitions + +### Phase 4: Thread Binding and Queues + +- implement `Connect To Current Thread` +- persist session-thread bindings +- cache unread messages for unbound sessions +- flush unread messages after binding +- implement per-thread serialized drain + +### Phase 5: TUI Control Panel + +- add `Clawbot` entry in `Ctrl-P` +- add session list panel +- add connect/disconnect/retry actions +- add session status snapshots + +### Phase 6: Final Answer Routing + +- hook final assistant output for bound threads +- forward final text to Feishu +- return runtime/model failures as Feishu error text + +## Deferred + +- `tui_app_server` parity +- non-Feishu providers +- group chat +- cards +- attachments/images/files +- sender identity injection into the thread +- approval flows in IM +- app-server-hosted clawbot runtime +- historical message backfill + +## Validation Commands + +Once implementation starts, validate with: + +```bash +cd /Users/bytedance/code/codex +git status --short +``` + +```bash +cd /Users/bytedance/code/codex/codex-rs +cargo test -p codex-clawbot +``` + +```bash +cd /Users/bytedance/code/codex/codex-rs +cargo test -p codex-tui +``` + +```bash +cd /Users/bytedance/code/codex +just argument-comment-lint +``` diff --git a/codex-rs/docs/codex_component_evolution.drawio b/codex-rs/docs/codex_component_evolution.drawio new file mode 100644 index 000000000..efc2ad8cb --- /dev/null +++ b/codex-rs/docs/codex_component_evolution.drawio @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/codex-rs/docs/codex_runtime_stack_swimlanes.drawio b/codex-rs/docs/codex_runtime_stack_swimlanes.drawio new file mode 100644 index 000000000..e968db112 --- /dev/null +++ b/codex-rs/docs/codex_runtime_stack_swimlanes.drawio @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/codex-rs/docs/codex_tool_design_principles.md b/codex-rs/docs/codex_tool_design_principles.md new file mode 100644 index 000000000..71adf4bcc --- /dev/null +++ b/codex-rs/docs/codex_tool_design_principles.md @@ -0,0 +1,238 @@ +# Codex Tool Design Principles [proposal] + +Status: proposal + +This document proposes a default design stance for Codex tools, based on direct user feedback gathered through structured prompts. It is intended to guide future tool design, tool API evolution, and approval UX decisions across Codex surfaces. + +## Goals + +Codex tools should optimize for the following, in order: + +1. Reliable task completion. +2. Strong traceability and execution evidence. +3. Low-friction operation with bounded safety controls. +4. Extensibility without excessive schema or UX complexity. + +The proposal explicitly does not optimize for maximum autonomy at any cost, nor for fully manual operation. The preferred stance is a balanced default with clear risk boundaries. + +## Core principles + +### 1. Prioritize task completion over tool purity + +Tools exist to help Codex complete user work, not to preserve a minimal or elegant abstraction at the expense of success rate. A tool system that is slightly redundant but materially more reliable is preferable to a perfectly uniform system that fails in real workflows. + +### 2. Use a mixed abstraction stack + +Codex should expose both: + +- low-level primitives such as command execution, patch application, browsing, and structured questioning +- a limited set of higher-level capabilities where the workflow is stable and repeatedly valuable + +This avoids forcing every task through brittle high-level tools while still leaving room for opinionated workflow helpers. + +### 3. Prefer medium-granularity tool boundaries + +Tool sets should avoid both extremes: + +- too few tools, where each tool becomes overloaded and hard to steer +- too many tools, where discovery, selection, and maintenance become noisy + +A medium-granularity tool catalog is preferred, with clear responsibilities and predictable calling conventions. + +### 4. Keep schemas compact, but enforce strong conventions + +Tool APIs should generally prefer semi-structured inputs and outputs: + +- keep core fields explicit and typed +- allow flexible text where rigid structure adds little value +- avoid repeating long schema descriptions that inflate token usage + +Short descriptions plus strong naming conventions are preferred over verbose per-call schema payloads. + +### 5. Default to balanced autonomy + +The preferred default is: + +- routine, low-risk work proceeds automatically +- higher-risk work requires approval +- obviously dangerous actions are hard-blocked rather than delegated to model judgment + +This keeps the system usable without normalizing destructive or unverifiable behavior. + +## Approval and safety model + +### 6. Favor preauthorization over repetitive prompts + +When approval is required, the preferred model is scoped preauthorization rather than repeated per-command interruptions. Command-prefix-based authorization is a strong default because it maps well to practical workflows such as tests, builds, and diagnostics. + +Approval UX should be short and purpose-driven. The system should explain what it wants to do and why, without burying the user in unnecessary detail. + +### 7. Hard-block a small set of dangerous actions + +Some actions should not be left to ordinary approval flow. A narrow class of obviously dangerous operations should be blocked by policy, regardless of model confidence. + +### 8. Safety should be risk-layered, not uniformly restrictive + +The preferred tradeoff is not "safety first" in every case. It is capability-first within a risk-layered system: + +- low-risk operations should have low friction +- high-risk operations should have stronger controls +- unverifiable claims of execution should never be acceptable + +## Observability and trust + +### 9. Every tool call should be traceable + +Traceability is not optional. At minimum, the system should retain a per-call record of: + +- tool name +- high-level purpose +- key inputs +- timestamp +- result status +- failure reason when applicable + +### 10. Execution should carry lightweight proof + +When a tool performs an action, the default evidence bar should be lightweight but real. For command execution, the preferred baseline is: + +- exit code +- key output + +Where possible, tools should also point to external verification artifacts such as file changes, test results, or request logs. + +### 11. Progress updates should stay concise + +The preferred progress model is "key steps visible." Users generally want: + +- a short command or action summary +- a short result summary + +Progress should not devolve into log spam, but it also should not disappear until the end. + +## Tool behavior + +### 12. Failures should trigger limited self-healing + +When a tool fails, the preferred behavior is: + +1. Attempt a small, safe amount of self-recovery. +2. If recovery fails, report the failure with context. + +The system should avoid both extremes of infinite silent retries and immediate escalation for every routine hiccup. + +### 13. Retry semantics should be tool-declared + +Retryability should not be guessed blindly. Tools should explicitly declare whether they are safe to retry and under what conditions. The platform may still make the final retry decision, but the tool must provide the signal. + +### 14. Cancellation should support compensation where possible + +For long-running or side-effecting tools, cancellation should aim for more than best-effort termination. The preferred model is cancellation plus compensation or cleanup when feasible. + +### 15. Partial success should be a first-class result + +Tools should be able to return an explicit partial-success state rather than collapsing mixed outcomes into either "success" or "failure." This is especially important for multi-step operations and tool batches. + +## State, recovery, and compatibility + +### 16. Prefer lightweight short-term state + +Tools should primarily rely on current task context and lightweight short-term state. Long-term memory can exist, but it should be explicit and bounded rather than silently shaping every interaction. + +### 17. Resume should combine checkpoints and logs + +Long-running work should be recoverable through a hybrid of: + +- explicit checkpoints where useful +- event or call logs for replay and auditing + +### 18. Preserve strong API compatibility + +Tool APIs should evolve carefully. Backward compatibility is a strong preference, with migration paths when changes are unavoidable. + +## Tool-specific guidance + +### 19. `web` should make search a first-class capability + +The most important gap in web tooling is native search. Browsing alone is not enough. The preferred default search result shape is: + +- title +- short summary +- source + +Browser-style interaction can expand later, with session and cookie handling as an especially valuable follow-on capability. + +### 20. `question` should be used for constraints and decisions, not as a crutch + +`question` is best suited for: + +- collecting user constraints and preferences up front +- handling meaningful branch decisions mid-task + +It should not be used as a substitute for basic local context gathering. Repetitive questioning before inspecting the workspace erodes trust quickly. + +The most valuable near-term improvements to `question` are: + +- multi-select answers +- ranked multi-select +- default values +- basic validation for required fields and simple formats + +Conditional branching may be useful later, but is not required for an initial improved version. + +### 21. Subagents should stay explicitly user-authorized + +Delegation is valuable, but it should remain visible and intentional. The preferred model is a main agent orchestrator that uses subagents only after explicit user authorization. + +## Tool API guidance + +The following fields are strong candidates for a common tool contract: + +- `name` +- `description` +- `trigger_conditions` +- `dependencies` +- `timeout` +- `retry` +- `idempotent` +- `cancelable` +- `requires_approval` + +Tool cards or UI surfaces should, at minimum, expose: + +- name +- description +- trigger conditions +- dependencies + +Implementation details such as caching can remain mostly hidden unless they materially affect behavior. + +## Non-goals + +This proposal does not attempt to: + +- define a single universal schema for every tool +- require all tools to expose the same UI surface +- replace product-specific policy or sandbox constraints +- prescribe a complete browser automation model + +## Evaluation criteria + +A future tool or API change is aligned with this proposal if it tends to improve one or more of the following without materially regressing the others: + +- completion reliability +- user trust +- evidence and traceability +- approval ergonomics +- token efficiency +- extensibility + +## Next steps + +Potential follow-up work: + +1. Define a minimal shared tool metadata contract. +2. Redesign `web` around first-class search plus clearer result objects. +3. Expand `question` to support ranked multi-select and defaults. +4. Standardize per-call execution evidence and partial-success reporting. +5. Review approval flows for repeated same-class command prompts. diff --git a/codex-rs/docs/loop-v2-design.md b/codex-rs/docs/loop-v2-design.md new file mode 100644 index 000000000..e66e9c399 --- /dev/null +++ b/codex-rs/docs/loop-v2-design.md @@ -0,0 +1,133 @@ +# Loop V2 Design + +## Architecture + +Loop v2 is split into three layers: + +1. `codex-loop` + - data model + - parser + - trigger queue persistence + - validation helpers +2. native TUI `Loop Manager` + - forms + - menus + - queue ordering + - user-visible loop state +3. `codex-loop-runtime` + - execution setting normalization + - runtime prompt/input construction + - reusable runtime helpers shared by loop surfaces +4. loop runtime orchestration + - timer scheduling + - hidden-thread lifecycle + - before-turn and after-turn hooks + +## Source of Truth + +The design intentionally avoids compatibility shims: + +- trigger bindings are authoritative for what can fire +- trigger queue files are authoritative for cross-loop ordering +- runtime should not reconstruct triggers from older timer-only fields + +## Runtime Modes + +### Embed + +Runs in the main-thread execution path. + +Typical uses: + +- before-turn prompt steering +- after-turn lightweight follow-up checks +- timer-driven main-thread automation + +Risk: + +- `timer + embed` can effectively automate the main thread, so the UI must surface that clearly + +### Ephemeral + +Runs in a hidden thread with no retained rollout. + +Typical uses: + +- short-lived checks +- one-off timer work +- hidden status inspection + +### Persistent + +Runs in a hidden thread with a retained rollout. + +Typical uses: + +- long-lived directors +- background managers +- loop agents that accumulate private state over time + +## Response Delivery + +Loop v2 keeps the response mode explicit: + +- `as_assistant` +- `as_user` + +Empty loop completions are still valid; they simply do not inject a main-thread message. + +## Queue Synchronization Rules + +When a trigger binding is added: + +- append a queue entry to the corresponding phase queue + +When a trigger binding is deleted: + +- remove its queue entry + +When a trigger binding changes phase: + +- remove it from the old queue +- append it to the new queue + +When a loop is deleted: + +- remove all queue entries that reference that loop + +## UI Design + +`Loop Manager` stays the top-level menu name. + +Top-level actions: + +- create loop agent +- trigger queue +- loop list + +Per-loop trigger editing is local: + +- add trigger +- edit trigger +- enable/disable trigger +- delete trigger + +Global ordering is separate: + +- trigger queue + - timer + - before turn + - after turn + +This separation keeps loop ownership and workspace execution policy distinct. + +## Future Backend Service + +`claw gateway` should be a separate backend service that can reuse: + +- trigger definitions +- queue ordering +- forwarding rules +- response routing + +It should not be embedded into the first loop v2 runtime rollout. diff --git a/codex-rs/docs/loop-v2-proposal.md b/codex-rs/docs/loop-v2-proposal.md new file mode 100644 index 000000000..e441332b6 --- /dev/null +++ b/codex-rs/docs/loop-v2-proposal.md @@ -0,0 +1,103 @@ +# Loop V2 Proposal + +## Goal + +Promote `/loop` from a timer-only helper into a general automation runtime with: + +- multiple trigger kinds +- explicit context modes +- explicit response modes +- per-loop execution/security settings +- workspace-level trigger ordering + +`Loop Manager` remains the single TUI surface for loop creation, editing, and execution order. + +## Scope + +Loop v2 covers: + +- triggers: + - `manual` + - `timer` + - `before_turn` + - `after_turn` +- context modes: + - `embed` + - `ephemeral` + - `persistent` +- response modes: + - `as_assistant` + - `as_user` +- security modes: + - `inherited` + - `specified_directory` + +Out of scope for phase 1: + +- channel transport backends such as `claw gateway` +- cross-device or remote loop management APIs + +## Key Decisions + +### Loop-local triggers, workspace-global ordering + +A loop owns its trigger bindings, but it does not own global trigger order. + +- loop config can add, edit, enable, disable, and delete its own trigger bindings +- workspace `Trigger Queue` controls cross-loop ordering for each trigger phase + +### Fail-fast model changes + +Loop v2 does not try to preserve silent compatibility with older timer-only loop files. + +- trigger bindings are the source of truth +- queue files are the source of truth for cross-loop ordering +- old timer-only shape is not auto-upgraded via hidden fallback logic + +### Context modes + +- `embed`: execute directly in the main-thread context +- `ephemeral`: execute in a hidden short-lived thread +- `persistent`: execute in a hidden long-lived thread with its own rollout + +### Response modes + +- empty results are valid and simply do not inject a main-thread message +- `as_assistant` mirrors the loop result as an assistant message +- `as_user` submits the loop result as a user message + +Loop-generated user submissions must not recursively trigger loops again. + +### Security modes + +- `inherited`: follow the parent thread execution policy +- `specified_directory`: inherit the parent policy, but constrain file writes to configured roots and allow a per-loop cwd override + +## TUI Surfaces + +`Loop Manager` contains: + +- `Create Loop Agent` +- `Trigger Queue` +- loop list + +Per-loop actions contain: + +- prompt +- action +- context mode +- response mode +- security mode +- execution settings +- triggers +- run now +- enable/disable +- delete + +`Trigger Queue` contains: + +- timer queue +- before turn queue +- after turn queue + +Each queue supports reordering across loops. diff --git a/codex-rs/docs/loop-v2-spec.md b/codex-rs/docs/loop-v2-spec.md new file mode 100644 index 000000000..5b4ee0a02 --- /dev/null +++ b/codex-rs/docs/loop-v2-spec.md @@ -0,0 +1,141 @@ +# Loop V2 Spec + +## Persistent Model + +Each workspace stores: + +- `.codex/loop_timers.json` + - loop definitions +- `.codex/loop_trigger_queues.json` + - workspace ordering for trigger phases + +## Loop Agent + +```rust +PersistedLoopTimer { + id, + prompt, + action, + enabled, + mode, + context_mode, + response_mode, + security_mode, + execution, + trigger_bindings, + rollout_path, + created_at_unix_seconds, + last_scheduled_at_unix_seconds, + last_completed_at_unix_seconds, +} +``` + +Notes: + +- `mode` is retained for current runtime ownership of hidden state +- `context_mode` is the v2 semantic mode +- `trigger_bindings` are authoritative + +## Trigger Bindings + +```rust +LoopTriggerBinding { + id, + enabled, + kind, +} +``` + +```rust +LoopTriggerKind = + Timer { schedule } + | BeforeTurn + | AfterTurn +``` + +`manual` is always available as `Run Now` and is not persisted as a binding. + +## Trigger Queue + +```rust +PersistedLoopTriggerQueuesFile { + queues: Vec, +} +``` + +```rust +LoopTriggerQueue { + phase, + entries, +} +``` + +```rust +LoopTriggerQueueEntry { + loop_id, + binding_id, +} +``` + +Queue phases: + +- `timer` +- `before_turn` +- `after_turn` + +## Ordering + +When a phase fires: + +1. look up the queue for that phase +2. walk queue entries from top to bottom +3. resolve `(loop_id, binding_id)` +4. skip missing, disabled, or invalid entries +5. execute matched loop triggers in order + +## Response Semantics + +- `as_assistant`: append assistant message +- `as_user`: queue a user message into the main thread +- empty loop completions are allowed implicitly; they simply produce no main-thread message + +Loop-generated user messages do not re-trigger loop hooks. + +## Agent Tooling + +Loop agents are managed through the shared harness/service layer. +TUI Codex sessions also expose a model-visible `loop` function tool that forwards +create, list, info, update, and delete operations into that shared service. +The service writes workspace-local loop metadata into `.codex/loop_timers.json` +and `.codex/loop_trigger_queues.json`. + +## Security Semantics + +### Inherited + +- inherit parent thread approvals +- inherit parent thread tool access +- inherit parent thread cwd unless overridden elsewhere + +### Specified Directory + +- inherit parent approvals and tool access +- constrain writable roots to configured directories +- optionally override cwd + +## Hook Semantics + +### Before Turn + +- phase fires before a main-thread user turn is submitted +- loop may contribute additional user-context text +- runtime decides how to merge that contribution into the outgoing turn + +### After Turn + +- phase fires after the assistant final response completes + +### Timer + +- phase fires when a timer trigger becomes due +- if multiple timer triggers become due around the same time, queue ordering decides execution order diff --git a/codex-rs/ext/Cargo.toml b/codex-rs/ext/Cargo.toml new file mode 100644 index 000000000..74204d0a8 --- /dev/null +++ b/codex-rs/ext/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "codex-ext" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_ext" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-accounts = { workspace = true } +codex-btw = { workspace = true } +codex-core = { workspace = true } +codex-threadmessages = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +base64 = { workspace = true } +codex-app-server-protocol = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/ext/README.md b/codex-rs/ext/README.md new file mode 100644 index 000000000..38d72f9e9 --- /dev/null +++ b/codex-rs/ext/README.md @@ -0,0 +1,16 @@ +# codex-ext + +`codex-ext` is the fork-owned extension layer for long-lived customization. + +Current MVP scope: + +- stable capability-negotiation shapes for future plugin runtimes +- jump-target and workspace-spawn models for fork-owned host extensions +- hidden ephemeral-discussion capability models for fork-owned `/btw`-style flows +- persisted multi-account pool state +- default account router model for threshold-based fallback +- data structures intentionally decoupled from current TUI/core internals + +This crate does not yet load WASM modules. The immediate goal is to keep the +fork-specific policy surface isolated so future upgrades only touch a narrow set +of host integration points. diff --git a/codex-rs/ext/src/host_api.rs b/codex-rs/ext/src/host_api.rs new file mode 100644 index 000000000..7ce555451 --- /dev/null +++ b/codex-rs/ext/src/host_api.rs @@ -0,0 +1,133 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostCapability { + pub name: String, + pub version: u32, +} + +impl HostCapability { + pub fn new(name: impl Into, version: u32) -> Self { + Self { + name: name.into(), + version, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequirement { + pub name: String, + pub minimum_version: u32, +} + +impl CapabilityRequirement { + pub fn new(name: impl Into, minimum_version: u32) -> Self { + Self { + name: name.into(), + minimum_version, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostCapabilities { + pub capabilities: Vec, +} + +impl Default for HostCapabilities { + fn default() -> Self { + Self::codex_mvp() + } +} + +impl HostCapabilities { + pub fn codex_mvp() -> Self { + Self { + capabilities: vec![ + HostCapability::new("app-start", /*version*/ 1), + HostCapability::new("session-start", /*version*/ 1), + HostCapability::new("before-turn-start", /*version*/ 1), + HostCapability::new("before-tool-call", /*version*/ 1), + HostCapability::new("after-tool-call", /*version*/ 1), + HostCapability::new("account-routing", /*version*/ 1), + HostCapability::new("control-panel", /*version*/ 1), + HostCapability::new("ephemeral-discussion", /*version*/ 1), + HostCapability::new("jump-to-message", /*version*/ 1), + HostCapability::new("spawn-with-workspace", /*version*/ 1), + ], + } + } + + pub fn supports(&self, requirement: &CapabilityRequirement) -> bool { + self.capabilities.iter().any(|capability| { + capability.name == requirement.name && capability.version >= requirement.minimum_version + }) + } + + pub fn negotiate(&self, requirements: &[CapabilityRequirement]) -> PluginNegotiation { + let mut accepted = Vec::new(); + let mut missing = Vec::new(); + + for requirement in requirements { + if self.supports(requirement) { + accepted.push(requirement.clone()); + } else { + missing.push(requirement.clone()); + } + } + + PluginNegotiation { accepted, missing } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginNegotiation { + pub accepted: Vec, + pub missing: Vec, +} + +impl PluginNegotiation { + pub fn is_compatible(&self) -> bool { + self.missing.is_empty() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::CapabilityRequirement; + use super::HostCapabilities; + + #[test] + fn codex_mvp_capabilities_support_account_routing() { + let capabilities = HostCapabilities::codex_mvp(); + let negotiation = capabilities.negotiate(&[ + CapabilityRequirement::new("account-routing", 1), + CapabilityRequirement::new("control-panel", 1), + CapabilityRequirement::new("ephemeral-discussion", 1), + CapabilityRequirement::new("jump-to-message", 1), + CapabilityRequirement::new("spawn-with-workspace", 1), + ]); + + assert!(negotiation.is_compatible()); + assert_eq!(negotiation.accepted.len(), 5); + assert_eq!(negotiation.missing.len(), 0); + } + + #[test] + fn negotiation_reports_missing_versions() { + let capabilities = HostCapabilities::codex_mvp(); + let negotiation = + capabilities.negotiate(&[CapabilityRequirement::new("before-turn-start", 2)]); + + assert!(!negotiation.is_compatible()); + assert_eq!(negotiation.accepted.len(), 0); + assert_eq!( + negotiation.missing, + vec![CapabilityRequirement::new("before-turn-start", 2)] + ); + } +} diff --git a/codex-rs/ext/src/lib.rs b/codex-rs/ext/src/lib.rs new file mode 100644 index 000000000..2912cbdbb --- /dev/null +++ b/codex-rs/ext/src/lib.rs @@ -0,0 +1,45 @@ +pub mod host_api; +pub mod workspace_spawn; + +pub use codex_accounts::ACCOUNT_POOL_STATE_RELATIVE_PATH; +pub use codex_accounts::AccountLimitSignal; +pub use codex_accounts::AccountManagementProfile; +pub use codex_accounts::AccountPoolState; +pub use codex_accounts::AccountPoolStore; +pub use codex_accounts::AccountRateLimitSnapshot; +pub use codex_accounts::AccountRateLimitWindow; +pub use codex_accounts::AccountRecord; +pub use codex_accounts::AccountRouterDecision; +pub use codex_accounts::AccountRouterDecisionReason; +pub use codex_accounts::AccountUsageWindow; +pub use codex_accounts::AccountUsageWindowKind; +pub use codex_accounts::DefaultAccountRouter; +pub use codex_accounts::LimitSignalKind; +pub use codex_accounts::MANAGED_ACCOUNTS_RELATIVE_DIR; +pub use codex_accounts::ManagedAccountAuthStore; +pub use codex_accounts::ManagedAccountSnapshot; +pub use codex_accounts::RouteTurnRequest; +pub use codex_accounts::RoutingTrigger; +pub use codex_accounts::UsageEstimateSource; +pub use codex_accounts::activate_managed_account; +pub use codex_accounts::infer_limit_signal; +pub use codex_accounts::load_current_managed_account_snapshot; +pub use codex_accounts::persist_current_managed_account_snapshot; +pub use codex_accounts::persist_managed_account_auth_snapshot; +pub use codex_btw::BtwCapability; +pub use codex_btw::BtwPersistence; +pub use codex_btw::BtwPolicy; +pub use codex_btw::BtwResultAction; +pub use codex_btw::BtwSurface; +pub use codex_btw::BtwToolPolicy; +pub use codex_threadmessages::JumpCatalog; +pub use codex_threadmessages::JumpTarget; +pub use codex_threadmessages::JumpTargetKind; +pub use host_api::CapabilityRequirement; +pub use host_api::HostCapabilities; +pub use host_api::HostCapability; +pub use host_api::PluginNegotiation; +pub use workspace_spawn::WorkspaceSpawnError; +pub use workspace_spawn::WorkspaceSpawnRequest; +pub use workspace_spawn::WorkspaceSpawnResolution; +pub use workspace_spawn::resolve_workspace_spawn; diff --git a/codex-rs/ext/src/workspace_spawn.rs b/codex-rs/ext/src/workspace_spawn.rs new file mode 100644 index 000000000..7a717350c --- /dev/null +++ b/codex-rs/ext/src/workspace_spawn.rs @@ -0,0 +1,132 @@ +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceSpawnRequest { + pub cwd: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSpawnResolution { + pub cwd: PathBuf, + pub inherited: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceSpawnError { + EmptyPath, + MissingDirectory(PathBuf), + NotADirectory(PathBuf), +} + +impl fmt::Display for WorkspaceSpawnError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyPath => write!(f, "workspace path cannot be empty"), + Self::MissingDirectory(path) => { + write!(f, "workspace {} does not exist", path.display()) + } + Self::NotADirectory(path) => { + write!(f, "workspace {} is not a directory", path.display()) + } + } + } +} + +impl std::error::Error for WorkspaceSpawnError {} + +pub fn resolve_workspace_spawn( + parent_cwd: &Path, + request: &WorkspaceSpawnRequest, +) -> Result { + let Some(requested_cwd) = request.cwd.as_deref() else { + return Ok(WorkspaceSpawnResolution { + cwd: parent_cwd.to_path_buf(), + inherited: true, + }); + }; + + let requested_cwd = requested_cwd.trim(); + if requested_cwd.is_empty() { + return Err(WorkspaceSpawnError::EmptyPath); + } + + let resolved = PathBuf::from(requested_cwd); + let resolved = if resolved.is_absolute() { + resolved + } else { + parent_cwd.join(resolved) + }; + + if !resolved.exists() { + return Err(WorkspaceSpawnError::MissingDirectory(resolved)); + } + if !resolved.is_dir() { + return Err(WorkspaceSpawnError::NotADirectory(resolved)); + } + + Ok(WorkspaceSpawnResolution { + cwd: resolved, + inherited: false, + }) +} + +#[cfg(test)] +mod tests { + use super::WorkspaceSpawnError; + use super::WorkspaceSpawnRequest; + use super::resolve_workspace_spawn; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn resolve_workspace_spawn_inherits_parent_cwd_by_default() { + let tempdir = tempdir().expect("tempdir"); + + let resolution = + resolve_workspace_spawn(tempdir.path(), &WorkspaceSpawnRequest { cwd: None }) + .expect("resolution"); + + assert_eq!(resolution.cwd, tempdir.path()); + assert!(resolution.inherited); + } + + #[test] + fn resolve_workspace_spawn_resolves_relative_path() { + let tempdir = tempdir().expect("tempdir"); + let child = tempdir.path().join("worker-a"); + std::fs::create_dir(&child).expect("create dir"); + + let resolution = resolve_workspace_spawn( + tempdir.path(), + &WorkspaceSpawnRequest { + cwd: Some("worker-a".to_string()), + }, + ) + .expect("resolution"); + + assert_eq!(resolution.cwd, child); + assert!(!resolution.inherited); + } + + #[test] + fn resolve_workspace_spawn_rejects_missing_directory() { + let tempdir = tempdir().expect("tempdir"); + + let err = resolve_workspace_spawn( + tempdir.path(), + &WorkspaceSpawnRequest { + cwd: Some("missing".to_string()), + }, + ) + .expect_err("missing path should fail"); + + assert_eq!( + err, + WorkspaceSpawnError::MissingDirectory(tempdir.path().join("missing")) + ); + } +} diff --git a/codex-rs/install_local.sh b/codex-rs/install_local.sh new file mode 100755 index 000000000..dfc75ca8d --- /dev/null +++ b/codex-rs/install_local.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +sudo install -m 0755 target/debug/codex /usr/local/bin/codex +sudo codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime /usr/local/bin/codex diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b726eeed8..02e673bb9 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -1084,13 +1084,12 @@ pub(crate) async fn obtain_api_key( } #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; - use super::TokenEndpointErrorDetail; use super::parse_token_endpoint_error; use super::redact_sensitive_query_value; use super::redact_sensitive_url_parts; use super::sanitize_url_for_logging; + use pretty_assertions::assert_eq; #[test] fn parse_token_endpoint_error_prefers_error_description() { diff --git a/codex-rs/loop-runtime/Cargo.toml b/codex-rs/loop-runtime/Cargo.toml new file mode 100644 index 000000000..2bb57c3f9 --- /dev/null +++ b/codex-rs/loop-runtime/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "codex-loop-runtime" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_loop_runtime" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +chrono = { workspace = true } +codex-loop = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/loop-runtime/src/lib.rs b/codex-rs/loop-runtime/src/lib.rs new file mode 100644 index 000000000..e63e5383f --- /dev/null +++ b/codex-rs/loop-runtime/src/lib.rs @@ -0,0 +1,256 @@ +use codex_loop::LoopContextMode; +use codex_loop::LoopSecurityMode; +use codex_loop::PersistedLoopExecutionSettings; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::Path; + +mod manage; +mod service; + +pub use manage::DeleteLoopResult; +pub use manage::LoopInfo; +pub use manage::LoopSummary; +pub use manage::UpdateLoopRequest; +pub use manage::delete_loop; +pub use manage::get_loop; +pub use manage::list_loops; +pub use manage::update_loop; +pub use service::CreateLoopRequest; +pub use service::CreateLoopResult; +pub use service::CreateLoopServiceError; +pub use service::CreateLoopTriggerRequest; +pub use service::create_loop; + +#[derive(Debug)] +pub struct LoopRuntimeOverrides { + pub cwd: Option, + pub sandbox_policy: Option, + pub developer_instructions: String, +} + +pub fn build_loop_runtime_overrides( + security_mode: LoopSecurityMode, + settings: &PersistedLoopExecutionSettings, + workspace_cwd: &Path, + inherited_network_access: bool, +) -> Result { + let cwd = settings + .cwd + .as_ref() + .map(|cwd| resolve_absolute_path(cwd, workspace_cwd)) + .transpose()?; + + let sandbox_policy = if matches!(security_mode, LoopSecurityMode::SpecifiedDirectory) { + if settings.writable_roots.is_empty() { + return Err( + "Loop security mode `specified_directory` requires at least one writable directory." + .to_string(), + ); + } + + let writable_roots = settings + .writable_roots + .iter() + .map(|path| resolve_absolute_path(path, workspace_cwd)) + .collect::, _>>()?; + + Some(SandboxPolicy::WorkspaceWrite { + writable_roots, + read_only_access: ReadOnlyAccess::FullAccess, + network_access: inherited_network_access, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }) + } else { + None + }; + + Ok(LoopRuntimeOverrides { + cwd, + sandbox_policy, + developer_instructions: loop_developer_instructions(security_mode, settings), + }) +} + +pub fn build_loop_phase_input( + context_mode: LoopContextMode, + prompt: &str, + recent_main_messages: &[String], + current_user_turn: Option<&str>, + last_assistant_message: Option<&str>, +) -> String { + let mut sections = Vec::new(); + if matches!( + context_mode, + LoopContextMode::Ephemeral | LoopContextMode::Persistent + ) && !recent_main_messages.is_empty() + { + sections.push(format!( + "Recent main-thread messages:\n{}", + recent_main_messages.join("\n\n") + )); + } + if let Some(current_user_turn) = current_user_turn.filter(|text| !text.trim().is_empty()) { + sections.push(format!( + "Current main-thread user turn:\n{current_user_turn}" + )); + } + if let Some(last_assistant_message) = + last_assistant_message.filter(|text| !text.trim().is_empty()) + { + sections.push(format!( + "Latest main-thread assistant response:\n{last_assistant_message}" + )); + } + sections.push(format!("Original loop prompt:\n{prompt}")); + sections.join("\n\n") +} + +fn loop_developer_instructions( + security_mode: LoopSecurityMode, + settings: &PersistedLoopExecutionSettings, +) -> String { + let mut parts = vec![ + "This is a hidden `/loop` execution thread.".to_string(), + "Use the current main-thread context only as background.".to_string(), + "Keep work scoped to this scheduled task.".to_string(), + ]; + if let Some(cwd) = &settings.cwd { + parts.push(format!( + "Use `{}` as the execution working directory.", + cwd.display() + )); + } else { + parts.push( + "Use the same working directory as the parent thread unless the runtime overrides it." + .to_string(), + ); + } + match security_mode { + LoopSecurityMode::Inherited => { + parts.push( + "Use the same permissions and tool access as the parent thread unless the runtime overrides them." + .to_string(), + ); + } + LoopSecurityMode::SpecifiedDirectory => { + let writable_roots = settings + .writable_roots + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + parts.push(format!( + "Only write files inside these directories: {writable_roots}." + )); + } + } + parts.join(" ") +} + +fn resolve_absolute_path(path: &Path, workspace_cwd: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + workspace_cwd.join(path) + }; + let absolute = std::fs::canonicalize(&absolute) + .map_err(|err| format!("Path `{}` is unavailable: {err}", absolute.display()))?; + AbsolutePathBuf::from_absolute_path(absolute.clone()) + .map_err(|err| format!("Invalid path `{}`: {err}", absolute.display())) +} + +#[cfg(test)] +mod tests { + use super::LoopRuntimeOverrides; + use super::build_loop_phase_input; + use super::build_loop_runtime_overrides; + use codex_loop::LoopContextMode; + use codex_loop::LoopSecurityMode; + use codex_loop::PersistedLoopExecutionSettings; + use codex_protocol::protocol::ReadOnlyAccess; + use codex_protocol::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn build_loop_runtime_overrides_requires_writable_roots_for_specified_directory() { + let workspace = tempdir().expect("tempdir"); + let err = build_loop_runtime_overrides( + LoopSecurityMode::SpecifiedDirectory, + &PersistedLoopExecutionSettings::default(), + workspace.path(), + false, + ) + .expect_err("specified directory should require writable roots"); + + assert_eq!( + err, + "Loop security mode `specified_directory` requires at least one writable directory." + ); + } + + #[test] + fn build_loop_runtime_overrides_returns_workspace_write_policy() { + let workspace = tempdir().expect("tempdir"); + std::fs::create_dir_all(workspace.path().join("src")).expect("mkdir"); + let overrides = build_loop_runtime_overrides( + LoopSecurityMode::SpecifiedDirectory, + &PersistedLoopExecutionSettings { + cwd: Some("src".into()), + writable_roots: vec!["src".into()], + }, + workspace.path(), + true, + ) + .expect("runtime overrides"); + + let expected = LoopRuntimeOverrides { + cwd: Some( + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( + workspace.path().join("src").canonicalize().expect("canonical cwd"), + ) + .expect("absolute cwd"), + ), + sandbox_policy: Some(SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( + workspace.path().join("src").canonicalize().expect("canonical root"), + ) + .expect("absolute root"), + ], + read_only_access: ReadOnlyAccess::FullAccess, + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }), + developer_instructions: + "This is a hidden `/loop` execution thread. Use the current main-thread context only as background. Keep work scoped to this scheduled task. Use `src` as the execution working directory. Only write files inside these directories: src.".to_string(), + }; + + assert_eq!(overrides.cwd, expected.cwd); + assert_eq!(overrides.sandbox_policy, expected.sandbox_policy); + assert_eq!( + overrides.developer_instructions, + expected.developer_instructions + ); + } + + #[test] + fn build_loop_phase_input_skips_main_thread_history_for_embed() { + let input = build_loop_phase_input( + LoopContextMode::Embed, + "review progress", + &["user: hi".to_string(), "assistant: hello".to_string()], + Some("continue"), + Some("done"), + ); + + assert_eq!( + input, + "Current main-thread user turn:\ncontinue\n\nLatest main-thread assistant response:\ndone\n\nOriginal loop prompt:\nreview progress" + ); + } +} diff --git a/codex-rs/loop-runtime/src/manage.rs b/codex-rs/loop-runtime/src/manage.rs new file mode 100644 index 000000000..3ea1ac0dd --- /dev/null +++ b/codex-rs/loop-runtime/src/manage.rs @@ -0,0 +1,421 @@ +use codex_loop::LoopContextMode; +use codex_loop::LoopResponseMode; +use codex_loop::LoopSchedule; +use codex_loop::LoopSecurityMode; +use codex_loop::LoopTriggerBinding; +use codex_loop::LoopTriggerKind; +use codex_loop::PersistedLoopTimer; +use codex_loop::load_loop_timers; +use codex_loop::load_loop_trigger_queues; +use codex_loop::loop_item_name; +use codex_loop::loop_timers_path; +use codex_loop::loop_trigger_queues_path; +use codex_loop::parse_loop_cwd; +use codex_loop::parse_loop_schedule; +use codex_loop::parse_loop_writable_roots; +use codex_loop::prompt_prefix; +use codex_loop::sync_trigger_queues_with_timers; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use crate::CreateLoopServiceError; +use crate::CreateLoopTriggerRequest; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LoopSummary { + pub id: String, + pub display_name: String, + pub prompt_prefix: String, + pub context_mode: LoopContextMode, + pub response_mode: LoopResponseMode, + pub security_mode: LoopSecurityMode, + pub enabled: bool, + pub triggers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LoopInfo { + pub timer: PersistedLoopTimer, + pub timers_path: String, + pub trigger_queue_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DeleteLoopResult { + pub id: String, + pub timers_path: String, + pub trigger_queue_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UpdateLoopRequest { + pub id: String, + pub prompt: Option, + pub action: Option>, + pub context_mode: Option, + pub response_mode: Option, + pub security_mode: Option, + pub cwd: Option>, + pub writable_roots: Option>, + pub enabled: Option, + pub trigger_bindings: Option>, +} + +pub fn list_loops(workspace_cwd: &Path) -> Result, CreateLoopServiceError> { + let timers = load_loop_timers(workspace_cwd).map_err(io_error)?; + Ok(timers + .timers + .into_iter() + .map(|timer| LoopSummary { + id: timer.id.clone(), + display_name: loop_item_name(&timer), + prompt_prefix: prompt_prefix(&timer.prompt), + context_mode: timer.context_mode, + response_mode: timer.response_mode, + security_mode: timer.security_mode, + enabled: timer.enabled, + triggers: timer + .trigger_bindings + .iter() + .map(LoopTriggerBinding::selection_name) + .collect(), + }) + .collect()) +} + +pub fn get_loop(id: &str, workspace_cwd: &Path) -> Result { + let timers = load_loop_timers(workspace_cwd).map_err(io_error)?; + let Some(timer) = timers.timers.into_iter().find(|timer| timer.id == id) else { + return Err(CreateLoopServiceError::InvalidRequest(format!( + "loop `{id}` does not exist" + ))); + }; + Ok(LoopInfo { + timer, + timers_path: loop_timers_path(workspace_cwd).display().to_string(), + trigger_queue_path: loop_trigger_queues_path(workspace_cwd) + .display() + .to_string(), + }) +} + +pub fn delete_loop( + id: &str, + workspace_cwd: &Path, +) -> Result { + let mut timers_file = load_loop_timers(workspace_cwd).map_err(io_error)?; + let original_len = timers_file.timers.len(); + timers_file.timers.retain(|timer| timer.id != id); + if timers_file.timers.len() == original_len { + return Err(CreateLoopServiceError::InvalidRequest(format!( + "loop `{id}` does not exist" + ))); + } + + persist_timers_and_queues(workspace_cwd, timers_file.timers)?; + + Ok(DeleteLoopResult { + id: id.to_string(), + timers_path: loop_timers_path(workspace_cwd).display().to_string(), + trigger_queue_path: loop_trigger_queues_path(workspace_cwd) + .display() + .to_string(), + }) +} + +pub fn update_loop( + request: UpdateLoopRequest, + workspace_cwd: &Path, +) -> Result { + let mut timers_file = load_loop_timers(workspace_cwd).map_err(io_error)?; + let Some(timer) = timers_file + .timers + .iter_mut() + .find(|timer| timer.id == request.id) + else { + return Err(CreateLoopServiceError::InvalidRequest(format!( + "loop `{}` does not exist", + request.id + ))); + }; + + if let Some(prompt) = request.prompt { + let prompt = prompt.trim().to_string(); + if prompt.is_empty() { + return Err(CreateLoopServiceError::InvalidRequest( + "prompt must not be empty".to_string(), + )); + } + timer.prompt = prompt; + } + + if let Some(action) = request.action { + timer.action = action + .as_deref() + .map(str::trim) + .filter(|action| !action.is_empty()) + .map(ToOwned::to_owned); + } + + if let Some(context_mode) = request.context_mode { + timer.context_mode = context_mode; + timer.mode = if matches!(context_mode, LoopContextMode::Persistent) { + codex_loop::LoopMode::Persistent + } else { + codex_loop::LoopMode::OneShot + }; + } + if let Some(response_mode) = request.response_mode { + timer.response_mode = response_mode; + } + if let Some(enabled) = request.enabled { + timer.enabled = enabled; + } + + let mut execution = timer.execution.clone(); + if let Some(cwd) = request.cwd { + execution.cwd = cwd + .as_deref() + .filter(|cwd| !cwd.trim().is_empty()) + .map(|cwd| parse_loop_cwd(cwd, workspace_cwd)) + .transpose() + .map_err(CreateLoopServiceError::InvalidRequest)?; + } + if let Some(writable_roots) = request.writable_roots { + execution.writable_roots = if writable_roots.is_empty() { + Vec::new() + } else { + parse_loop_writable_roots(&writable_roots.join("\n"), workspace_cwd) + .map_err(CreateLoopServiceError::InvalidRequest)? + }; + } + + if let Some(security_mode) = request.security_mode { + timer.security_mode = security_mode; + } + match timer.security_mode { + LoopSecurityMode::Inherited => { + if !execution.writable_roots.is_empty() { + return Err(CreateLoopServiceError::InvalidRequest( + "writable_roots requires security_mode set to specified_directory".to_string(), + )); + } + } + LoopSecurityMode::SpecifiedDirectory => { + if execution.writable_roots.is_empty() { + return Err(CreateLoopServiceError::InvalidRequest( + "specified_directory requires at least one writable root".to_string(), + )); + } + } + } + timer.execution = execution; + + if let Some(trigger_bindings) = request.trigger_bindings { + if trigger_bindings.is_empty() { + return Err(CreateLoopServiceError::InvalidRequest( + "trigger_bindings must not be empty".to_string(), + )); + } + timer.trigger_bindings = trigger_bindings + .into_iter() + .enumerate() + .map(|(index, trigger)| { + let kind = match trigger { + CreateLoopTriggerRequest::Timer { schedule } => LoopTriggerKind::Timer { + schedule: parse_loop_schedule(schedule.trim()) + .map_err(CreateLoopServiceError::InvalidRequest)?, + }, + CreateLoopTriggerRequest::BeforeTurn => LoopTriggerKind::BeforeTurn, + CreateLoopTriggerRequest::AfterTurn => LoopTriggerKind::AfterTurn, + }; + Ok(LoopTriggerBinding { + id: format!("trigger-{}", index + 1), + enabled: true, + kind, + }) + }) + .collect::, CreateLoopServiceError>>()?; + } + + timer.schedule = timer + .trigger_bindings + .iter() + .find_map(|binding| match &binding.kind { + LoopTriggerKind::Timer { schedule } => Some(schedule.clone()), + LoopTriggerKind::BeforeTurn | LoopTriggerKind::AfterTurn => None, + }) + .unwrap_or(LoopSchedule::Interval { + display: "1h".to_string(), + seconds: 60 * 60, + }); + + let updated_timer = timer.clone(); + persist_timers_and_queues(workspace_cwd, timers_file.timers)?; + + Ok(LoopInfo { + timer: updated_timer, + timers_path: loop_timers_path(workspace_cwd).display().to_string(), + trigger_queue_path: loop_trigger_queues_path(workspace_cwd) + .display() + .to_string(), + }) +} + +fn persist_timers_and_queues( + workspace_cwd: &Path, + mut timers: Vec, +) -> Result<(), CreateLoopServiceError> { + timers.sort_by(|left, right| left.id.cmp(&right.id)); + let mut queues = load_loop_trigger_queues(workspace_cwd).map_err(io_error)?; + let timers_by_id = timers + .iter() + .cloned() + .map(|timer| (timer.id.clone(), timer)) + .collect::>(); + sync_trigger_queues_with_timers(&mut queues, &timers_by_id); + + let timers_path = loop_timers_path(workspace_cwd); + let trigger_queues_path = loop_trigger_queues_path(workspace_cwd); + if let Some(parent) = timers_path.parent() { + fs::create_dir_all(parent).map_err(|err| { + CreateLoopServiceError::Io(format!( + "failed to create loop workspace metadata directory: {err}" + )) + })?; + } + let timers_json = serde_json::to_string_pretty(&codex_loop::PersistedLoopTimersFile { timers }) + .map_err(|err| { + CreateLoopServiceError::Internal(format!("failed to serialize loop timers: {err}")) + })?; + fs::write(&timers_path, timers_json).map_err(|err| { + CreateLoopServiceError::Io(format!("failed to persist loop timers: {err}")) + })?; + + let queues_json = serde_json::to_string_pretty(&queues).map_err(|err| { + CreateLoopServiceError::Internal(format!("failed to serialize loop trigger queues: {err}")) + })?; + fs::write(&trigger_queues_path, queues_json).map_err(|err| { + CreateLoopServiceError::Io(format!("failed to persist loop trigger queues: {err}")) + })?; + Ok(()) +} + +fn io_error(err: std::io::Error) -> CreateLoopServiceError { + CreateLoopServiceError::Io(err.to_string()) +} + +#[cfg(test)] +mod tests { + use super::DeleteLoopResult; + use super::UpdateLoopRequest; + use super::delete_loop; + use super::get_loop; + use super::list_loops; + use super::update_loop; + use crate::CreateLoopRequest; + use crate::CreateLoopTriggerRequest; + use crate::create_loop; + use codex_loop::LoopContextMode; + use codex_loop::LoopResponseMode; + use codex_loop::LoopSecurityMode; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + fn seed_loop(temp: &tempfile::TempDir) { + create_loop( + CreateLoopRequest { + id: Some("director".to_string()), + prompt: "review progress".to_string(), + action: None, + context_mode: LoopContextMode::Persistent, + response_mode: LoopResponseMode::Assistant, + security_mode: LoopSecurityMode::Inherited, + cwd: None, + writable_roots: Vec::new(), + trigger: CreateLoopTriggerRequest::AfterTurn, + }, + temp.path(), + ) + .expect("seed loop"); + } + + #[test] + fn list_and_get_loop_return_persisted_loop() { + let temp = tempdir().expect("tempdir"); + seed_loop(&temp); + + let listed = list_loops(temp.path()).expect("list loops"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "director"); + assert_eq!(listed[0].display_name, "director"); + + let info = get_loop("director", temp.path()).expect("get loop"); + assert_eq!(info.timer.id, "director"); + assert_eq!(info.timer.prompt, "review progress"); + } + + #[test] + fn update_loop_rewrites_prompt_and_triggers() { + let temp = tempdir().expect("tempdir"); + seed_loop(&temp); + + let updated = update_loop( + UpdateLoopRequest { + id: "director".to_string(), + prompt: Some("check blockers".to_string()), + action: Some(Some("file a follow-up".to_string())), + context_mode: Some(LoopContextMode::Ephemeral), + response_mode: Some(LoopResponseMode::User), + security_mode: Some(LoopSecurityMode::Inherited), + cwd: Some(None), + writable_roots: Some(Vec::new()), + enabled: Some(false), + trigger_bindings: Some(vec![CreateLoopTriggerRequest::Timer { + schedule: "10m".to_string(), + }]), + }, + temp.path(), + ) + .expect("update loop"); + + assert_eq!(updated.timer.prompt, "check blockers"); + assert_eq!(updated.timer.action.as_deref(), Some("file a follow-up")); + assert_eq!(updated.timer.context_mode, LoopContextMode::Ephemeral); + assert_eq!(updated.timer.response_mode, LoopResponseMode::User); + assert_eq!(updated.timer.enabled, false); + assert_eq!(updated.timer.trigger_bindings.len(), 1); + assert_eq!( + updated.timer.trigger_bindings[0].selection_name(), + "timer · 10m" + ); + } + + #[test] + fn delete_loop_removes_persisted_loop() { + let temp = tempdir().expect("tempdir"); + seed_loop(&temp); + + let result = delete_loop("director", temp.path()).expect("delete loop"); + assert_eq!( + result, + DeleteLoopResult { + id: "director".to_string(), + timers_path: codex_loop::loop_timers_path(temp.path()) + .display() + .to_string(), + trigger_queue_path: codex_loop::loop_trigger_queues_path(temp.path()) + .display() + .to_string(), + } + ); + assert!( + list_loops(temp.path()) + .expect("list after delete") + .is_empty() + ); + } +} diff --git a/codex-rs/loop-runtime/src/service.rs b/codex-rs/loop-runtime/src/service.rs new file mode 100644 index 000000000..8340dee5a --- /dev/null +++ b/codex-rs/loop-runtime/src/service.rs @@ -0,0 +1,395 @@ +use chrono::Utc; +use codex_loop::LoopContextMode; +use codex_loop::LoopMode; +use codex_loop::LoopResponseMode; +use codex_loop::LoopSchedule; +use codex_loop::LoopSecurityMode; +use codex_loop::LoopTriggerBinding; +use codex_loop::LoopTriggerKind; +use codex_loop::PersistedLoopExecutionSettings; +use codex_loop::PersistedLoopTimer; +use codex_loop::PersistedLoopTimersFile; +use codex_loop::PersistedLoopTriggerQueuesFile; +use codex_loop::load_loop_timers; +use codex_loop::load_loop_trigger_queues; +use codex_loop::loop_timers_path; +use codex_loop::loop_trigger_queues_path; +use codex_loop::parse_loop_cwd; +use codex_loop::parse_loop_schedule; +use codex_loop::parse_loop_writable_roots; +use codex_loop::sync_trigger_queues_with_timers; +use codex_loop::validate_loop_id; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct CreateLoopRequest { + pub id: Option, + pub prompt: String, + pub action: Option, + pub context_mode: LoopContextMode, + pub response_mode: LoopResponseMode, + pub security_mode: LoopSecurityMode, + pub cwd: Option, + #[serde(default)] + pub writable_roots: Vec, + pub trigger: CreateLoopTriggerRequest, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CreateLoopTriggerRequest { + Timer { schedule: String }, + BeforeTurn, + AfterTurn, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct CreateLoopResult { + pub id: String, + pub context_mode: LoopContextMode, + pub response_mode: LoopResponseMode, + pub security_mode: LoopSecurityMode, + pub trigger_kind: String, + pub timers_path: String, + pub trigger_queue_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateLoopServiceError { + InvalidRequest(String), + Io(String), + Internal(String), +} + +impl fmt::Display for CreateLoopServiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidRequest(message) | Self::Io(message) | Self::Internal(message) => { + write!(f, "{message}") + } + } + } +} + +impl std::error::Error for CreateLoopServiceError {} + +pub fn create_loop( + request: CreateLoopRequest, + workspace_cwd: &Path, +) -> Result { + let prompt = request.prompt.trim(); + if prompt.is_empty() { + return Err(CreateLoopServiceError::InvalidRequest( + "prompt must not be empty".to_string(), + )); + } + + let id = request + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + match request.context_mode { + LoopContextMode::Persistent => { + let Some(id) = id else { + return Err(CreateLoopServiceError::InvalidRequest( + "persistent loops require an id".to_string(), + )); + }; + validate_loop_id(id).map_err(CreateLoopServiceError::InvalidRequest)?; + } + LoopContextMode::Embed | LoopContextMode::Ephemeral => { + if id.is_some() { + return Err(CreateLoopServiceError::InvalidRequest( + "only persistent loops may set id".to_string(), + )); + } + } + } + + let trigger_kind = match request.trigger { + CreateLoopTriggerRequest::Timer { schedule } => LoopTriggerKind::Timer { + schedule: parse_loop_schedule(schedule.trim()) + .map_err(CreateLoopServiceError::InvalidRequest)?, + }, + CreateLoopTriggerRequest::BeforeTurn => LoopTriggerKind::BeforeTurn, + CreateLoopTriggerRequest::AfterTurn => LoopTriggerKind::AfterTurn, + }; + + let execution = PersistedLoopExecutionSettings { + cwd: request + .cwd + .as_deref() + .filter(|cwd| !cwd.trim().is_empty()) + .map(|cwd| parse_loop_cwd(cwd, workspace_cwd)) + .transpose() + .map_err(CreateLoopServiceError::InvalidRequest)?, + writable_roots: if request.writable_roots.is_empty() { + Vec::new() + } else { + parse_loop_writable_roots(&request.writable_roots.join("\n"), workspace_cwd) + .map_err(CreateLoopServiceError::InvalidRequest)? + }, + }; + + match request.security_mode { + LoopSecurityMode::Inherited => { + if !execution.writable_roots.is_empty() { + return Err(CreateLoopServiceError::InvalidRequest( + "writable_roots requires security_mode set to specified_directory".to_string(), + )); + } + } + LoopSecurityMode::SpecifiedDirectory => { + if execution.writable_roots.is_empty() { + return Err(CreateLoopServiceError::InvalidRequest( + "specified_directory requires at least one writable root".to_string(), + )); + } + } + } + + let timer_id = id + .map(ToOwned::to_owned) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut timers_file = load_loop_timers(workspace_cwd) + .map_err(|err| CreateLoopServiceError::Io(err.to_string()))?; + if timers_file.timers.iter().any(|timer| timer.id == timer_id) { + return Err(CreateLoopServiceError::InvalidRequest(format!( + "loop `{timer_id}` already exists" + ))); + } + + let created_at = Utc::now().timestamp(); + timers_file.timers.push(PersistedLoopTimer { + id: timer_id.clone(), + mode: if matches!(request.context_mode, LoopContextMode::Persistent) { + LoopMode::Persistent + } else { + LoopMode::OneShot + }, + prompt: prompt.to_string(), + action: request + .action + .as_deref() + .map(str::trim) + .filter(|action| !action.is_empty()) + .map(ToOwned::to_owned), + context_mode: request.context_mode, + response_mode: request.response_mode, + security_mode: request.security_mode, + execution, + schedule: match &trigger_kind { + LoopTriggerKind::Timer { schedule } => schedule.clone(), + LoopTriggerKind::BeforeTurn | LoopTriggerKind::AfterTurn => LoopSchedule::Interval { + display: "1h".to_string(), + seconds: 60 * 60, + }, + }, + trigger_bindings: vec![LoopTriggerBinding { + id: "trigger-1".to_string(), + enabled: true, + kind: trigger_kind.clone(), + }], + enabled: true, + rollout_path: None, + created_at_unix_seconds: created_at, + last_scheduled_at_unix_seconds: None, + last_completed_at_unix_seconds: None, + }); + timers_file + .timers + .sort_by(|left, right| left.id.cmp(&right.id)); + + let mut queues = load_loop_trigger_queues(workspace_cwd) + .map_err(|err| CreateLoopServiceError::Io(err.to_string()))?; + let timers_by_id = timers_file + .timers + .iter() + .cloned() + .map(|timer| (timer.id.clone(), timer)) + .collect::>(); + sync_trigger_queues_with_timers(&mut queues, &timers_by_id); + + persist_loop_files(workspace_cwd, &timers_file, &queues)?; + + Ok(CreateLoopResult { + id: timer_id, + context_mode: request.context_mode, + response_mode: request.response_mode, + security_mode: request.security_mode, + trigger_kind: match trigger_kind { + LoopTriggerKind::Timer { .. } => "timer".to_string(), + LoopTriggerKind::BeforeTurn => "before_turn".to_string(), + LoopTriggerKind::AfterTurn => "after_turn".to_string(), + }, + timers_path: loop_timers_path(workspace_cwd).display().to_string(), + trigger_queue_path: loop_trigger_queues_path(workspace_cwd) + .display() + .to_string(), + }) +} + +fn persist_loop_files( + workspace_cwd: &Path, + timers_file: &PersistedLoopTimersFile, + queues: &PersistedLoopTriggerQueuesFile, +) -> Result<(), CreateLoopServiceError> { + let timers_path = loop_timers_path(workspace_cwd); + let trigger_queues_path = loop_trigger_queues_path(workspace_cwd); + if let Some(parent) = timers_path.parent() { + fs::create_dir_all(parent).map_err(|err| { + CreateLoopServiceError::Io(format!( + "failed to create loop workspace metadata directory: {err}" + )) + })?; + } + let timers_json = serde_json::to_string_pretty(timers_file).map_err(|err| { + CreateLoopServiceError::Internal(format!("failed to serialize loop timers: {err}")) + })?; + fs::write(&timers_path, timers_json).map_err(|err| { + CreateLoopServiceError::Io(format!("failed to persist loop timers: {err}")) + })?; + + let queues_json = serde_json::to_string_pretty(queues).map_err(|err| { + CreateLoopServiceError::Internal(format!("failed to serialize loop trigger queues: {err}")) + })?; + fs::write(&trigger_queues_path, queues_json).map_err(|err| { + CreateLoopServiceError::Io(format!("failed to persist loop trigger queues: {err}")) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::CreateLoopRequest; + use super::CreateLoopResult; + use super::CreateLoopServiceError; + use super::CreateLoopTriggerRequest; + use super::create_loop; + use codex_loop::LoopContextMode; + use codex_loop::LoopResponseMode; + use codex_loop::LoopSecurityMode; + use codex_loop::load_loop_timers; + use codex_loop::load_loop_trigger_queues; + use codex_loop::loop_timers_path; + use codex_loop::loop_trigger_queues_path; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn create_persistent_loop_writes_timer_and_queue() { + let temp = tempdir().expect("create tempdir"); + let docs = temp.path().join("docs"); + fs::create_dir_all(&docs).expect("create docs"); + + let result = create_loop( + CreateLoopRequest { + id: Some("director".to_string()), + prompt: "review progress".to_string(), + action: None, + context_mode: LoopContextMode::Persistent, + response_mode: LoopResponseMode::Assistant, + security_mode: LoopSecurityMode::SpecifiedDirectory, + cwd: Some("docs".to_string()), + writable_roots: vec!["docs".to_string()], + trigger: CreateLoopTriggerRequest::AfterTurn, + }, + temp.path(), + ) + .expect("create loop"); + + assert_eq!( + result, + CreateLoopResult { + id: "director".to_string(), + context_mode: LoopContextMode::Persistent, + response_mode: LoopResponseMode::Assistant, + security_mode: LoopSecurityMode::SpecifiedDirectory, + trigger_kind: "after_turn".to_string(), + timers_path: loop_timers_path(temp.path()).display().to_string(), + trigger_queue_path: loop_trigger_queues_path(temp.path()).display().to_string(), + } + ); + + let timers = load_loop_timers(temp.path()).expect("load timers"); + assert_eq!(timers.timers.len(), 1); + assert_eq!(timers.timers[0].id, "director"); + assert_eq!(timers.timers[0].context_mode, LoopContextMode::Persistent); + assert_eq!( + timers.timers[0].execution.cwd, + Some(std::path::PathBuf::from("docs")) + ); + assert_eq!( + timers.timers[0].execution.writable_roots, + vec![std::path::PathBuf::from("docs")] + ); + + let queues = load_loop_trigger_queues(temp.path()).expect("load queues"); + assert_eq!(queues.queues.len(), 3); + assert_eq!( + codex_loop::queue_entries_for_phase(&queues, codex_loop::LoopTriggerPhase::AfterTurn) + .iter() + .map(|entry| (entry.loop_id.clone(), entry.binding_id.clone())) + .collect::>(), + vec![("director".to_string(), "trigger-1".to_string())] + ); + } + + #[test] + fn create_loop_rejects_non_persistent_ids() { + let temp = tempdir().expect("create tempdir"); + let err = create_loop( + CreateLoopRequest { + id: Some("temp-worker".to_string()), + prompt: "check status".to_string(), + action: None, + context_mode: LoopContextMode::Ephemeral, + response_mode: LoopResponseMode::Assistant, + security_mode: LoopSecurityMode::Inherited, + cwd: None, + writable_roots: Vec::new(), + trigger: CreateLoopTriggerRequest::BeforeTurn, + }, + temp.path(), + ) + .expect_err("reject id"); + + assert_eq!( + err, + CreateLoopServiceError::InvalidRequest("only persistent loops may set id".to_string()) + ); + } + + #[test] + fn create_loop_rejects_missing_specified_directory_roots() { + let temp = tempdir().expect("create tempdir"); + let err = create_loop( + CreateLoopRequest { + id: None, + prompt: "check status".to_string(), + action: None, + context_mode: LoopContextMode::Embed, + response_mode: LoopResponseMode::User, + security_mode: LoopSecurityMode::SpecifiedDirectory, + cwd: None, + writable_roots: Vec::new(), + trigger: CreateLoopTriggerRequest::BeforeTurn, + }, + temp.path(), + ) + .expect_err("reject missing roots"); + + assert_eq!( + err, + CreateLoopServiceError::InvalidRequest( + "specified_directory requires at least one writable root".to_string() + ) + ); + } +} diff --git a/codex-rs/loop/Cargo.toml b/codex-rs/loop/Cargo.toml new file mode 100644 index 000000000..ca2c99e09 --- /dev/null +++ b/codex-rs/loop/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "codex-loop" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_loop" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +chrono = { workspace = true, features = ["serde"] } +codex-utils-absolute-path = { workspace = true } +cron = "0.16.0" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/loop/src/command.rs b/codex-rs/loop/src/command.rs new file mode 100644 index 000000000..25b082de0 --- /dev/null +++ b/codex-rs/loop/src/command.rs @@ -0,0 +1,280 @@ +use chrono::DateTime; +use chrono::TimeZone; +use chrono::Utc; +use cron::Schedule; +use serde::Deserialize; +use serde::Serialize; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LoopSchedule { + Interval { display: String, seconds: u64 }, + Cron { display: String, normalized: String }, +} + +impl LoopSchedule { + pub fn display(&self) -> &str { + match self { + Self::Interval { display, .. } | Self::Cron { display, .. } => display, + } + } + + pub fn next_due_after( + &self, + last_scheduled_at_unix_seconds: i64, + now: DateTime, + ) -> DateTime { + match self { + Self::Interval { seconds, .. } => { + let interval = i64::try_from(*seconds).unwrap_or(i64::MAX).max(1); + let next = if last_scheduled_at_unix_seconds >= now.timestamp() { + last_scheduled_at_unix_seconds.saturating_add(interval) + } else { + let elapsed = now + .timestamp() + .saturating_sub(last_scheduled_at_unix_seconds); + let skipped_intervals = elapsed / interval; + last_scheduled_at_unix_seconds.saturating_add( + skipped_intervals.saturating_add(1).saturating_mul(interval), + ) + }; + unix_seconds_to_utc(next).unwrap_or(now) + } + Self::Cron { normalized, .. } => Schedule::from_str(normalized) + .ok() + .and_then(|schedule| { + unix_seconds_to_utc(last_scheduled_at_unix_seconds) + .and_then(|last| schedule.after(&last).next()) + }) + .map(|next| next.with_timezone(&Utc)) + .filter(|next| *next > now) + .unwrap_or(now), + } + } + + pub fn first_due_after_creation(&self, now: DateTime) -> DateTime { + match self { + Self::Interval { seconds, .. } => { + let interval = i64::try_from(*seconds).unwrap_or(i64::MAX).max(1); + unix_seconds_to_utc(now.timestamp().saturating_add(interval)).unwrap_or(now) + } + Self::Cron { normalized, .. } => Schedule::from_str(normalized) + .ok() + .and_then(|schedule| schedule.after(&now).next()) + .map(|next| next.with_timezone(&Utc)) + .unwrap_or(now), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum LoopMode { + OneShot, + #[default] + Persistent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoopCommand { + Focus { + id: String, + }, + Create { + id: Option, + schedule: LoopSchedule, + prompt: String, + }, +} + +pub fn parse_loop_command(spec: &str) -> Result { + let spec = spec.trim(); + if spec.is_empty() { + return Err( + "Usage: /loop or /loop " + .to_string(), + ); + } + + if let Ok((schedule, prompt)) = parse_schedule_and_prompt(spec) { + if prompt.trim().is_empty() { + return Err("expected a prompt after the schedule".to_string()); + } + return Ok(LoopCommand::Create { + id: None, + schedule, + prompt, + }); + } + + let tokens = spec.split_whitespace().collect::>(); + if tokens.len() == 1 { + return Ok(LoopCommand::Focus { + id: tokens[0].to_string(), + }); + } + + let id = tokens[0].trim(); + validate_loop_id(id)?; + let rest = spec[id.len()..].trim(); + let (schedule, prompt) = parse_schedule_and_prompt(rest)?; + if prompt.trim().is_empty() { + return Err("expected a prompt after the schedule".to_string()); + } + Ok(LoopCommand::Create { + id: Some(id.to_string()), + schedule, + prompt, + }) +} + +pub fn parse_loop_schedule(spec: &str) -> Result { + let (schedule, prompt) = parse_schedule_and_prompt(spec)?; + if !prompt.is_empty() { + return Err("expected only a schedule".to_string()); + } + Ok(schedule) +} + +pub fn validate_loop_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("loop id cannot be empty".to_string()); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err("loop id must use only letters, digits, underscores, or hyphens".to_string()); + } + Ok(()) +} + +fn parse_schedule_and_prompt(spec: &str) -> Result<(LoopSchedule, String), String> { + let tokens = spec.split_whitespace().collect::>(); + if tokens.is_empty() { + return Err("expected a schedule".to_string()); + } + + if let Some(seconds) = parse_interval_seconds(tokens[0]) { + let prompt = spec[tokens[0].len()..].trim().to_string(); + return Ok(( + LoopSchedule::Interval { + display: tokens[0].to_string(), + seconds, + }, + prompt, + )); + } + + for field_count in [7usize, 6, 5] { + if tokens.len() < field_count { + continue; + } + let display = tokens[..field_count].join(" "); + let normalized = normalize_cron_expression(&display, field_count); + if Schedule::from_str(&normalized).is_ok() { + let prompt = tokens[field_count..].join(" "); + return Ok(( + LoopSchedule::Cron { + display, + normalized, + }, + prompt, + )); + } + } + + Err( + "could not parse the schedule; use `5m`-style intervals or a 5/6/7-field cron expression" + .to_string(), + ) +} + +fn parse_interval_seconds(token: &str) -> Option { + let mut index = 0usize; + let mut total = 0u64; + let bytes = token.as_bytes(); + while index < bytes.len() { + let digits_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + if digits_start == index || index >= bytes.len() { + return None; + } + let value = token[digits_start..index].parse::().ok()?; + let multiplier = match bytes[index] as char { + 's' => 1, + 'm' => 60, + 'h' => 60 * 60, + 'd' => 60 * 60 * 24, + _ => return None, + }; + total = total.checked_add(value.checked_mul(multiplier)?)?; + index += 1; + } + (total > 0).then_some(total) +} + +fn normalize_cron_expression(expression: &str, field_count: usize) -> String { + match field_count { + 5 => format!("0 {expression} *"), + 6 => format!("{expression} *"), + _ => expression.to_string(), + } +} + +fn unix_seconds_to_utc(unix_seconds: i64) -> Option> { + Utc.timestamp_opt(unix_seconds, 0).single() +} + +#[cfg(test)] +mod tests { + use super::LoopCommand; + use super::LoopSchedule; + use super::parse_loop_command; + use super::parse_loop_schedule; + use pretty_assertions::assert_eq; + + #[test] + fn parse_interval_loop_command() { + assert_eq!( + parse_loop_command("5m check status").expect("parse"), + LoopCommand::Create { + id: None, + schedule: LoopSchedule::Interval { + display: "5m".to_string(), + seconds: 300, + }, + prompt: "check status".to_string(), + } + ); + } + + #[test] + fn parse_persistent_loop_command() { + assert_eq!( + parse_loop_command("director 30m review status").expect("parse"), + LoopCommand::Create { + id: Some("director".to_string()), + schedule: LoopSchedule::Interval { + display: "30m".to_string(), + seconds: 1800, + }, + prompt: "review status".to_string(), + } + ); + } + + #[test] + fn parse_cron_schedule() { + assert_eq!( + parse_loop_schedule("*/5 * * * *").expect("schedule"), + LoopSchedule::Cron { + display: "*/5 * * * *".to_string(), + normalized: "0 */5 * * * * *".to_string(), + } + ); + } +} diff --git a/codex-rs/loop/src/execution.rs b/codex-rs/loop/src/execution.rs new file mode 100644 index 000000000..9c36f088a --- /dev/null +++ b/codex-rs/loop/src/execution.rs @@ -0,0 +1,170 @@ +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistedLoopExecutionSettings { + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub writable_roots: Vec, +} + +pub fn loop_execution_summary( + settings: &PersistedLoopExecutionSettings, + session_cwd: &Path, +) -> String { + let cwd = settings + .cwd + .as_ref() + .map(|path| format!("`{}`", path.display())) + .unwrap_or_else(|| format!("session default (`{}`)", session_cwd.display())); + let writable_scope = if settings.writable_roots.is_empty() { + "session default".to_string() + } else { + settings + .writable_roots + .iter() + .map(|path| format!("`{}`", path.display())) + .collect::>() + .join(", ") + }; + format!("CWD: {cwd}. Writable scope: {writable_scope}.") +} + +pub fn writable_roots_editor_text(settings: &PersistedLoopExecutionSettings) -> String { + settings + .writable_roots + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join("\n") +} + +pub fn cwd_editor_text(settings: &PersistedLoopExecutionSettings, session_cwd: &Path) -> String { + settings + .cwd + .clone() + .unwrap_or_else(|| session_cwd.to_path_buf()) + .display() + .to_string() +} + +pub fn parse_loop_writable_roots( + input: &str, + workspace_cwd: &Path, +) -> Result, String> { + let workspace_cwd = canonical_workspace_root(workspace_cwd)?; + let mut seen = BTreeSet::new(); + let mut writable_roots = Vec::new(); + + for raw_line in input.lines() { + let trimmed = raw_line.trim(); + if trimmed.is_empty() { + continue; + } + let absolute = resolve_pathbuf(PathBuf::from(trimmed), &workspace_cwd)?; + let metadata = fs::metadata(&absolute) + .map_err(|err| format!("Writable directory `{trimmed}` is unavailable: {err}"))?; + if !metadata.is_dir() { + return Err(format!( + "Writable directory `{trimmed}` is not a directory." + )); + } + + let persisted = normalize_persisted_path(absolute.as_path(), &workspace_cwd); + if seen.insert(persisted.clone()) { + writable_roots.push(persisted); + } + } + + Ok(writable_roots) +} + +pub fn parse_loop_cwd(input: &str, workspace_cwd: &Path) -> Result { + let workspace_cwd = canonical_workspace_root(workspace_cwd)?; + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("Working directory cannot be empty.".to_string()); + } + let absolute = resolve_pathbuf(PathBuf::from(trimmed), &workspace_cwd)?; + let metadata = fs::metadata(&absolute) + .map_err(|err| format!("Working directory `{trimmed}` is unavailable: {err}"))?; + if !metadata.is_dir() { + return Err(format!("Working directory `{trimmed}` is not a directory.")); + } + Ok(normalize_persisted_path(absolute.as_path(), &workspace_cwd)) +} + +fn canonical_workspace_root(workspace_cwd: &Path) -> Result { + fs::canonicalize(workspace_cwd).map_err(|err| { + format!( + "Workspace root `{}` is unavailable: {err}", + workspace_cwd.display() + ) + }) +} + +fn resolve_pathbuf(path: PathBuf, workspace_cwd: &Path) -> Result { + let absolute = if path.is_absolute() { + path + } else { + workspace_cwd.join(path) + }; + fs::canonicalize(&absolute) + .map_err(|err| format!("Path `{}` is unavailable: {err}", absolute.display())) +} + +fn normalize_persisted_path(path: &Path, workspace_cwd: &Path) -> PathBuf { + if let Ok(relative) = path.strip_prefix(workspace_cwd) { + if relative.as_os_str().is_empty() { + PathBuf::from(".") + } else { + relative.to_path_buf() + } + } else { + path.to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use super::parse_loop_cwd; + use super::parse_loop_writable_roots; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn parse_loop_cwd_persists_workspace_relative_path() { + let workspace = tempdir().expect("workspace tempdir"); + let docs = workspace.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + + let cwd = parse_loop_cwd("docs", workspace.path()).expect("parse cwd"); + + assert_eq!(cwd, std::path::PathBuf::from("docs")); + } + + #[test] + fn parse_loop_writable_roots_persist_workspace_relative_paths() { + let workspace = tempdir().expect("workspace tempdir"); + let docs = workspace.path().join("docs"); + let notes = workspace.path().join("notes"); + std::fs::create_dir_all(&docs).expect("create docs"); + std::fs::create_dir_all(¬es).expect("create notes"); + + let writable_roots = + parse_loop_writable_roots("docs\nnotes", workspace.path()).expect("parse roots"); + + assert_eq!( + writable_roots, + vec![ + std::path::PathBuf::from("docs"), + std::path::PathBuf::from("notes"), + ] + ); + } +} diff --git a/codex-rs/loop/src/lib.rs b/codex-rs/loop/src/lib.rs new file mode 100644 index 000000000..0d54c9cf6 --- /dev/null +++ b/codex-rs/loop/src/lib.rs @@ -0,0 +1,51 @@ +mod command; +mod execution; +mod model; +mod queue; +mod trigger; + +pub use command::LoopCommand; +pub use command::LoopMode; +pub use command::LoopSchedule; +pub use command::parse_loop_command; +pub use command::parse_loop_schedule; +pub use command::validate_loop_id; +pub use execution::PersistedLoopExecutionSettings; +pub use execution::cwd_editor_text; +pub use execution::loop_execution_summary; +pub use execution::parse_loop_cwd; +pub use execution::parse_loop_writable_roots; +pub use execution::writable_roots_editor_text; +pub use model::PersistedLoopTimer; +pub use model::PersistedLoopTimersFile; +pub use model::build_loop_result_user_message_with_action; +pub use model::effective_loop_context_mode; +pub use model::effective_loop_response_mode; +pub use model::effective_loop_security_mode; +pub use model::effective_timer_schedule; +pub use model::format_timestamp; +pub use model::load_loop_timers; +pub use model::loop_id_prefix; +pub use model::loop_item_name; +pub use model::loop_timers_path; +pub use model::next_due_for_timer; +pub use model::prompt_prefix; +pub use model::timer_descriptor; +pub use model::trigger_bindings; +pub use queue::LoopTriggerQueue; +pub use queue::LoopTriggerQueueEntry; +pub use queue::PersistedLoopTriggerQueuesFile; +pub use queue::QueueMoveDirection; +pub use queue::load_loop_trigger_queues; +pub use queue::loop_trigger_queues_path; +pub use queue::move_trigger_queue_entry; +pub use queue::queue_entries_for_phase; +pub use queue::sync_trigger_queues_with_timers; +pub use trigger::LoopContextMode; +pub use trigger::LoopResponseMode; +pub use trigger::LoopSecurityMode; +pub use trigger::LoopTriggerBinding; +pub use trigger::LoopTriggerKind; +pub use trigger::LoopTriggerPhase; +pub use trigger::legacy_timer_binding; +pub use trigger::next_trigger_binding_id; diff --git a/codex-rs/loop/src/model.rs b/codex-rs/loop/src/model.rs new file mode 100644 index 000000000..ed063a9de --- /dev/null +++ b/codex-rs/loop/src/model.rs @@ -0,0 +1,150 @@ +use chrono::DateTime; +use chrono::TimeZone; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use crate::command::LoopMode; +use crate::command::LoopSchedule; +use crate::execution::PersistedLoopExecutionSettings; +use crate::trigger::LoopContextMode; +use crate::trigger::LoopResponseMode; +use crate::trigger::LoopSecurityMode; +use crate::trigger::LoopTriggerBinding; +use crate::trigger::LoopTriggerKind; + +const LOOP_TIMER_FILE_NAME: &str = "loop_timers.json"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistedLoopTimersFile { + #[serde(default)] + pub timers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistedLoopTimer { + pub id: String, + #[serde(default)] + pub mode: LoopMode, + pub prompt: String, + #[serde(default)] + pub action: Option, + #[serde(default)] + pub context_mode: LoopContextMode, + #[serde(default)] + pub response_mode: LoopResponseMode, + #[serde(default)] + pub security_mode: LoopSecurityMode, + #[serde(default)] + pub execution: PersistedLoopExecutionSettings, + /// Legacy top-level timer schedule retained for backward compatibility while loops migrate to + /// explicit trigger bindings. + pub schedule: LoopSchedule, + #[serde(default)] + pub trigger_bindings: Vec, + pub enabled: bool, + #[serde(default)] + pub rollout_path: Option, + pub created_at_unix_seconds: i64, + pub last_scheduled_at_unix_seconds: Option, + pub last_completed_at_unix_seconds: Option, +} + +pub fn effective_loop_context_mode(timer: Option<&PersistedLoopTimer>) -> LoopContextMode { + timer.map_or(LoopContextMode::default(), |timer| timer.context_mode) +} + +pub fn effective_loop_response_mode(timer: Option<&PersistedLoopTimer>) -> LoopResponseMode { + timer.map_or(LoopResponseMode::default(), |timer| timer.response_mode) +} + +pub fn effective_loop_security_mode(timer: Option<&PersistedLoopTimer>) -> LoopSecurityMode { + timer.map_or(LoopSecurityMode::default(), |timer| timer.security_mode) +} + +pub fn trigger_bindings(timer: &PersistedLoopTimer) -> Vec { + timer.trigger_bindings.clone() +} + +pub fn effective_timer_schedule(timer: &PersistedLoopTimer) -> Option { + timer + .trigger_bindings + .iter() + .find_map(|binding| match &binding.kind { + LoopTriggerKind::Timer { schedule } if binding.enabled => Some(schedule.clone()), + _ => None, + }) +} + +pub fn timer_descriptor(timer: &PersistedLoopTimer) -> &'static str { + match timer.context_mode { + LoopContextMode::Embed => "embed", + LoopContextMode::Ephemeral => "ephemeral", + LoopContextMode::Persistent => "persistent", + } +} + +pub fn loop_item_name(timer: &PersistedLoopTimer) -> String { + match timer.context_mode { + LoopContextMode::Embed => format!("embed #{}", loop_id_prefix(&timer.id)), + LoopContextMode::Ephemeral => format!("ephemeral #{}", loop_id_prefix(&timer.id)), + LoopContextMode::Persistent => timer.id.clone(), + } +} + +pub fn loop_id_prefix(id: &str) -> String { + id.chars().take(8).collect() +} + +pub fn prompt_prefix(prompt: &str) -> String { + let prefix = prompt.chars().take(48).collect::(); + if prompt.chars().count() > 48 { + format!("{prefix}...") + } else { + prefix + } +} + +pub fn build_loop_result_user_message_with_action(result: &str, action: Option<&str>) -> String { + let Some(action) = action.map(str::trim).filter(|action| !action.is_empty()) else { + return result.to_string(); + }; + format!("{result}\n\nAdditional action:\n{action}") +} + +pub fn next_due_for_timer(timer: &PersistedLoopTimer, now: DateTime) -> Option> { + if !timer.enabled { + return None; + } + let schedule = effective_timer_schedule(timer)?; + match timer.last_scheduled_at_unix_seconds { + Some(last_scheduled_at) => Some(schedule.next_due_after(last_scheduled_at, now)), + None => Some(schedule.first_due_after_creation(now)), + } +} + +pub fn load_loop_timers(cwd: &Path) -> std::io::Result { + let path = loop_timers_path(cwd); + if !path.exists() { + return Ok(PersistedLoopTimersFile { timers: Vec::new() }); + } + let contents = fs::read_to_string(path)?; + serde_json::from_str(&contents).map_err(std::io::Error::other) +} + +pub fn loop_timers_path(cwd: &Path) -> PathBuf { + cwd.join(".codex").join(LOOP_TIMER_FILE_NAME) +} + +pub fn format_timestamp(unix_seconds: i64) -> String { + unix_seconds_to_utc(unix_seconds) + .map(|timestamp| timestamp.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| unix_seconds.to_string()) +} + +fn unix_seconds_to_utc(unix_seconds: i64) -> Option> { + Utc.timestamp_opt(unix_seconds, 0).single() +} diff --git a/codex-rs/loop/src/queue.rs b/codex-rs/loop/src/queue.rs new file mode 100644 index 000000000..0c8e7e0af --- /dev/null +++ b/codex-rs/loop/src/queue.rs @@ -0,0 +1,273 @@ +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use crate::model::PersistedLoopTimer; +use crate::trigger::LoopTriggerPhase; +use crate::trigger_bindings; + +const LOOP_TRIGGER_QUEUE_FILE_NAME: &str = "loop_trigger_queues.json"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistedLoopTriggerQueuesFile { + #[serde(default)] + pub queues: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LoopTriggerQueue { + pub phase: LoopTriggerPhase, + #[serde(default)] + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct LoopTriggerQueueEntry { + pub loop_id: String, + pub binding_id: String, +} + +pub fn load_loop_trigger_queues(cwd: &Path) -> std::io::Result { + let path = loop_trigger_queues_path(cwd); + if !path.exists() { + return Ok(PersistedLoopTriggerQueuesFile::default()); + } + let contents = fs::read_to_string(path)?; + serde_json::from_str(&contents).map_err(std::io::Error::other) +} + +pub fn loop_trigger_queues_path(cwd: &Path) -> PathBuf { + cwd.join(".codex").join(LOOP_TRIGGER_QUEUE_FILE_NAME) +} + +pub fn sync_trigger_queues_with_timers( + queues: &mut PersistedLoopTriggerQueuesFile, + timers: &BTreeMap, +) { + let valid_entries = timers + .iter() + .flat_map(|(loop_id, timer)| { + trigger_bindings(timer).into_iter().map(move |binding| { + ( + binding.kind.phase(), + LoopTriggerQueueEntry { + loop_id: loop_id.clone(), + binding_id: binding.id, + }, + ) + }) + }) + .fold( + BTreeMap::>::new(), + |mut map, (phase, entry)| { + map.entry(phase).or_default().push(entry); + map + }, + ); + + for phase in LoopTriggerPhase::USER_SELECTABLE { + let queue = ensure_queue(queues, phase); + let valid_for_phase = valid_entries + .get(&phase) + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + + queue + .entries + .retain(|entry| valid_for_phase.contains(entry)); + + for entry in valid_entries.get(&phase).into_iter().flatten() { + if !queue.entries.contains(entry) { + queue.entries.push(entry.clone()); + } + } + } +} + +pub fn move_trigger_queue_entry( + queues: &mut PersistedLoopTriggerQueuesFile, + phase: LoopTriggerPhase, + loop_id: &str, + binding_id: &str, + direction: QueueMoveDirection, +) -> bool { + let queue = ensure_queue(queues, phase); + let Some(index) = queue + .entries + .iter() + .position(|entry| entry.loop_id == loop_id && entry.binding_id == binding_id) + else { + return false; + }; + + let target = match direction { + QueueMoveDirection::Up if index > 0 => index - 1, + QueueMoveDirection::Down if index + 1 < queue.entries.len() => index + 1, + _ => return false, + }; + queue.entries.swap(index, target); + true +} + +pub fn queue_entries_for_phase( + queues: &PersistedLoopTriggerQueuesFile, + phase: LoopTriggerPhase, +) -> &[LoopTriggerQueueEntry] { + queues + .queues + .iter() + .find(|queue| queue.phase == phase) + .map_or(&[], |queue| queue.entries.as_slice()) +} + +fn ensure_queue( + queues: &mut PersistedLoopTriggerQueuesFile, + phase: LoopTriggerPhase, +) -> &mut LoopTriggerQueue { + if let Some(index) = queues.queues.iter().position(|queue| queue.phase == phase) { + &mut queues.queues[index] + } else { + queues.queues.push(LoopTriggerQueue { + phase, + entries: Vec::new(), + }); + let index = queues.queues.len().saturating_sub(1); + &mut queues.queues[index] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueueMoveDirection { + Up, + Down, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use std::collections::BTreeMap; + + use super::LoopTriggerQueueEntry; + use super::PersistedLoopTriggerQueuesFile; + use super::QueueMoveDirection; + use super::move_trigger_queue_entry; + use super::queue_entries_for_phase; + use super::sync_trigger_queues_with_timers; + use crate::LoopMode; + use crate::LoopSchedule; + use crate::LoopTriggerBinding; + use crate::LoopTriggerKind; + use crate::LoopTriggerPhase; + use crate::PersistedLoopExecutionSettings; + use crate::PersistedLoopTimer; + + fn sample_timer() -> PersistedLoopTimer { + PersistedLoopTimer { + id: "director".to_string(), + mode: LoopMode::Persistent, + prompt: "review progress".to_string(), + action: None, + execution: PersistedLoopExecutionSettings::default(), + schedule: LoopSchedule::Interval { + display: "5m".to_string(), + seconds: 300, + }, + enabled: true, + rollout_path: None, + created_at_unix_seconds: 1, + last_scheduled_at_unix_seconds: None, + last_completed_at_unix_seconds: None, + trigger_bindings: vec![ + LoopTriggerBinding { + id: "trigger-1".to_string(), + enabled: true, + kind: LoopTriggerKind::Timer { + schedule: LoopSchedule::Interval { + display: "5m".to_string(), + seconds: 300, + }, + }, + }, + LoopTriggerBinding { + id: "trigger-2".to_string(), + enabled: true, + kind: LoopTriggerKind::AfterTurn, + }, + ], + context_mode: crate::LoopContextMode::Persistent, + response_mode: crate::LoopResponseMode::Assistant, + security_mode: crate::LoopSecurityMode::Inherited, + } + } + + #[test] + fn sync_trigger_queues_adds_missing_entries() { + let mut timers = BTreeMap::new(); + timers.insert("director".to_string(), sample_timer()); + let mut queues = PersistedLoopTriggerQueuesFile::default(); + + sync_trigger_queues_with_timers(&mut queues, &timers); + + assert_eq!( + vec![LoopTriggerQueueEntry { + loop_id: "director".to_string(), + binding_id: "trigger-1".to_string(), + }], + queue_entries_for_phase(&queues, LoopTriggerPhase::Timer) + ); + assert_eq!( + vec![LoopTriggerQueueEntry { + loop_id: "director".to_string(), + binding_id: "trigger-2".to_string(), + }], + queue_entries_for_phase(&queues, LoopTriggerPhase::AfterTurn) + ); + } + + #[test] + fn move_trigger_queue_entry_swaps_neighbors() { + let mut queues = PersistedLoopTriggerQueuesFile { + queues: vec![super::LoopTriggerQueue { + phase: LoopTriggerPhase::BeforeTurn, + entries: vec![ + LoopTriggerQueueEntry { + loop_id: "a".to_string(), + binding_id: "trigger-1".to_string(), + }, + LoopTriggerQueueEntry { + loop_id: "b".to_string(), + binding_id: "trigger-1".to_string(), + }, + ], + }], + }; + + assert!(move_trigger_queue_entry( + &mut queues, + LoopTriggerPhase::BeforeTurn, + "b", + "trigger-1", + QueueMoveDirection::Up, + )); + + assert_eq!( + vec![ + LoopTriggerQueueEntry { + loop_id: "b".to_string(), + binding_id: "trigger-1".to_string(), + }, + LoopTriggerQueueEntry { + loop_id: "a".to_string(), + binding_id: "trigger-1".to_string(), + }, + ], + queue_entries_for_phase(&queues, LoopTriggerPhase::BeforeTurn) + ); + } +} diff --git a/codex-rs/loop/src/trigger.rs b/codex-rs/loop/src/trigger.rs new file mode 100644 index 000000000..62451b1ea --- /dev/null +++ b/codex-rs/loop/src/trigger.rs @@ -0,0 +1,197 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::command::LoopSchedule; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "snake_case")] +pub enum LoopTriggerPhase { + Timer, + BeforeTurn, + AfterTurn, +} + +impl LoopTriggerPhase { + pub const USER_SELECTABLE: [Self; 3] = [Self::Timer, Self::BeforeTurn, Self::AfterTurn]; + + pub fn title(self) -> &'static str { + match self { + Self::Timer => "Timer", + Self::BeforeTurn => "Before Turn", + Self::AfterTurn => "After Turn", + } + } + + pub fn description(self) -> &'static str { + match self { + Self::Timer => "Runs when timer-based loop triggers become due.", + Self::BeforeTurn => "Runs before a user turn is submitted into the main thread model.", + Self::AfterTurn => "Runs after the assistant final response completes.", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LoopTriggerKind { + Timer { schedule: LoopSchedule }, + BeforeTurn, + AfterTurn, +} + +impl LoopTriggerKind { + pub fn phase(&self) -> LoopTriggerPhase { + match self { + Self::Timer { .. } => LoopTriggerPhase::Timer, + Self::BeforeTurn => LoopTriggerPhase::BeforeTurn, + Self::AfterTurn => LoopTriggerPhase::AfterTurn, + } + } + + pub fn short_label(&self) -> String { + match self { + Self::Timer { schedule } => format!("timer · {}", schedule.display()), + Self::BeforeTurn => "before turn".to_string(), + Self::AfterTurn => "after turn".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LoopTriggerBinding { + pub id: String, + pub enabled: bool, + pub kind: LoopTriggerKind, +} + +impl LoopTriggerBinding { + pub fn selection_name(&self) -> String { + self.kind.short_label() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum LoopContextMode { + Embed, + Ephemeral, + #[default] + Persistent, +} + +impl LoopContextMode { + pub const USER_SELECTABLE: [Self; 3] = [Self::Embed, Self::Ephemeral, Self::Persistent]; + + pub fn title(self) -> &'static str { + match self { + Self::Embed => "Embed", + Self::Ephemeral => "Ephemeral", + Self::Persistent => "Persistent", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum LoopResponseMode { + #[default] + Assistant, + User, +} + +impl LoopResponseMode { + pub const USER_SELECTABLE: [Self; 2] = [Self::Assistant, Self::User]; + + pub fn title(self) -> &'static str { + match self { + Self::Assistant => "As Assistant Message", + Self::User => "As User Message", + } + } + + pub fn description(self) -> &'static str { + match self { + Self::Assistant => { + "Mirror the loop result into the main thread as an assistant message." + } + Self::User => "Submit the loop result back into the main thread as a user message.", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum LoopSecurityMode { + #[default] + Inherited, + SpecifiedDirectory, +} + +impl LoopSecurityMode { + pub const USER_SELECTABLE: [Self; 2] = [Self::Inherited, Self::SpecifiedDirectory]; + + pub fn title(self) -> &'static str { + match self { + Self::Inherited => "Inherited", + Self::SpecifiedDirectory => "Specified Directory", + } + } +} + +pub fn next_trigger_binding_id(bindings: &[LoopTriggerBinding]) -> String { + let next = bindings + .iter() + .filter_map(|binding| binding.id.strip_prefix("trigger-")) + .filter_map(|suffix| suffix.parse::().ok()) + .max() + .map_or(1, |current| current.saturating_add(1)); + format!("trigger-{next}") +} + +pub fn legacy_timer_binding(schedule: LoopSchedule) -> LoopTriggerBinding { + LoopTriggerBinding { + id: "trigger-1".to_string(), + enabled: true, + kind: LoopTriggerKind::Timer { schedule }, + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::LoopTriggerBinding; + use super::LoopTriggerKind; + use super::LoopTriggerPhase; + use super::legacy_timer_binding; + use super::next_trigger_binding_id; + use crate::LoopSchedule; + + #[test] + fn next_trigger_binding_id_skips_to_next_numeric_suffix() { + let bindings = vec![ + LoopTriggerBinding { + id: "trigger-1".to_string(), + enabled: true, + kind: LoopTriggerKind::BeforeTurn, + }, + LoopTriggerBinding { + id: "trigger-4".to_string(), + enabled: true, + kind: LoopTriggerKind::AfterTurn, + }, + ]; + + assert_eq!("trigger-5", next_trigger_binding_id(&bindings)); + } + + #[test] + fn legacy_timer_binding_uses_timer_phase() { + let binding = legacy_timer_binding(LoopSchedule::Interval { + display: "5m".to_string(), + seconds: 300, + }); + + assert_eq!(LoopTriggerPhase::Timer, binding.kind.phase()); + } +} diff --git a/codex-rs/threadmessages/Cargo.toml b/codex-rs/threadmessages/Cargo.toml new file mode 100644 index 000000000..b88c83870 --- /dev/null +++ b/codex-rs/threadmessages/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-threadmessages" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_threadmessages" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/threadmessages/src/lib.rs b/codex-rs/threadmessages/src/lib.rs new file mode 100644 index 000000000..0aa80583f --- /dev/null +++ b/codex-rs/threadmessages/src/lib.rs @@ -0,0 +1,123 @@ +use serde::Deserialize; +use serde::Serialize; + +const MAX_PREVIEW_CHARS: usize = 120; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum JumpTargetKind { + UserMessage, + AgentMessage, + Reasoning, + ToolCall, + Event, +} + +impl JumpTargetKind { + pub fn display_name(self) -> &'static str { + match self { + Self::UserMessage => "User Message", + Self::AgentMessage => "Agent Message", + Self::Reasoning => "Reasoning", + Self::ToolCall => "Tool Call", + Self::Event => "Event", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JumpTarget { + pub cell_index: usize, + pub ordinal: usize, + pub kind: JumpTargetKind, + pub title: String, + pub preview: String, +} + +impl JumpTarget { + pub fn new( + cell_index: usize, + ordinal: usize, + kind: JumpTargetKind, + preview: impl Into, + ) -> Self { + let preview = normalize_preview(preview.into()); + Self { + cell_index, + ordinal, + kind, + title: format!("{} {ordinal}", kind.display_name()), + preview, + } + } + + pub fn search_value(&self) -> String { + format!("{} {}", self.title, self.preview) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct JumpCatalog { + pub targets: Vec, +} + +impl JumpCatalog { + pub fn new(targets: Vec) -> Self { + Self { targets } + } + + pub fn is_empty(&self) -> bool { + self.targets.is_empty() + } + + pub fn len(&self) -> usize { + self.targets.len() + } +} + +fn normalize_preview(preview: String) -> String { + let collapsed = preview.split_whitespace().collect::>().join(" "); + let mut normalized = collapsed.trim().to_string(); + if normalized.len() > MAX_PREVIEW_CHARS { + normalized.truncate(MAX_PREVIEW_CHARS); + normalized.push_str("..."); + } + normalized +} + +#[cfg(test)] +mod tests { + use super::JumpCatalog; + use super::JumpTarget; + use super::JumpTargetKind; + use pretty_assertions::assert_eq; + + #[test] + fn jump_target_normalizes_preview_whitespace() { + let target = JumpTarget::new( + 4, + 2, + JumpTargetKind::AgentMessage, + " first line\n second line ", + ); + + assert_eq!(target.title, "Agent Message 2"); + assert_eq!(target.preview, "first line second line"); + assert_eq!( + target.search_value(), + "Agent Message 2 first line second line" + ); + } + + #[test] + fn jump_catalog_reports_size() { + let catalog = JumpCatalog::new(vec![JumpTarget::new( + 0, + 1, + JumpTargetKind::UserMessage, + "hello", + )]); + + assert_eq!(catalog.len(), 1); + assert!(!catalog.is_empty()); + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 4613ddcf8..9301f4587 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -29,25 +29,32 @@ base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } +codex-accounts = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } codex-chatgpt = { workspace = true } codex-client = { workspace = true } +codex-btw = { workspace = true } +codex-clawbot = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } +codex-ext = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-loop = { workspace = true } +codex-loop-runtime = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } +codex-threadmessages = { workspace = true } codex-tui-app-server = { workspace = true } codex-utils-approval-presets = { workspace = true } codex-utils-absolute-path = { workspace = true } @@ -60,6 +67,7 @@ codex-utils-sleep-inhibitor = { workspace = true } codex-utils-string = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } +cron = "0.16.0" derive_more = { workspace = true, features = ["is_variant"] } diffy = { workspace = true } dirs = { workspace = true } @@ -110,6 +118,7 @@ unicode-width = { workspace = true } url = { workspace = true } webbrowser = { workspace = true } uuid = { workspace = true } +which = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } @@ -122,7 +131,6 @@ hound = { version = "3.5", optional = true } libc = { workspace = true } [target.'cfg(windows)'.dependencies] -which = { workspace = true } windows-sys = { version = "0.52", features = [ "Win32_Foundation", "Win32_System_Console", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fe1cb6ae0..7ef98ef46 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -16,6 +16,9 @@ use crate::chatwidget::ExternalEditorState; 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::exec_command::strip_bash_lc_and_escape; use crate::external_editor; use crate::file_search::FileSearchManager; @@ -31,6 +34,10 @@ 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::rate_limits::ManagedAccountQuotaOutcome; +use crate::rate_limits::ManagedAccountQuotaUpdate; +use crate::rate_limits::fetch_managed_account_quota; +use crate::rate_limits::fetch_managed_account_quota_from_auth_dot_json; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; @@ -40,6 +47,13 @@ use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; +use chrono::Utc; +use codex_accounts::AccountPoolStore; +use codex_accounts::AccountRateLimitSnapshot; +use codex_accounts::AccountRateLimitWindow; +use codex_accounts::ManagedAccountAuthStore; +use codex_accounts::activate_managed_account; +use codex_accounts::persist_current_managed_account_snapshot; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::InProcessAppServerClient; @@ -57,6 +71,8 @@ use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; +use codex_clawbot::ProviderKind; +use codex_clawbot::ProviderOutboundAction; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ForkSnapshot; @@ -96,6 +112,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FinalOutput; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; @@ -136,11 +153,31 @@ use toml::Value as TomlValue; use uuid::Uuid; mod agent_navigation; +mod btw; +mod clawbot; +mod display_preferences_menu; +mod jump_navigation; +mod key_chord; +mod loop_create; +pub(crate) mod loop_timers; mod pending_interactive_replay; +mod thread_menu; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; +use self::btw::BtwSessionState; +use self::clawbot::control_panel_clawbot_item; +use self::clawbot::turns::PendingClawbotTurn; +use self::display_preferences_menu::control_panel_show_hide_item; +use self::display_preferences_menu::display_preferences_items; +use self::jump_navigation::build_jump_catalog; +use self::key_chord::KeyChordAction; +use self::key_chord::KeyChordResolution; +use self::key_chord::KeyChordState; +use self::loop_timers::LoopTimersState; use self::pending_interactive_replay::PendingInteractiveReplayState; +use self::thread_menu::control_panel_thread_item; +use self::thread_menu::thread_panel_items; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; @@ -169,6 +206,12 @@ 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 +} + /// Baseline cadence for periodic stream commit animation ticks. /// /// Smooth-mode streaming drains one line per tick, so this interval controls @@ -181,6 +224,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, } @@ -191,6 +235,7 @@ impl AppExitInfo { thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::Fatal(message.into()), } } @@ -205,6 +250,7 @@ pub(crate) enum AppRunControl { #[derive(Debug, Clone)] pub enum ExitReason { UserRequested, + RespawnRequested, Fatal(String), } @@ -255,6 +301,24 @@ fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorI } } +#[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(()) +} + fn config_warning_notifications(config: &Config) -> Vec { config .startup_warnings @@ -883,6 +947,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, }); } @@ -908,6 +973,7 @@ pub(crate) struct App { harness_overrides: ConfigOverrides, runtime_approval_policy_override: Option, runtime_sandbox_policy_override: Option, + display_preferences: DisplayPreferences, pub(crate) file_search: FileSearchManager, @@ -929,6 +995,7 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + key_chord: KeyChordState, /// 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 @@ -956,6 +1023,12 @@ pub(crate) struct App { pending_shutdown_exit_thread_id: Option, windows_sandbox: WindowsSandboxState, + btw_session: Option, + loop_timers: LoopTimersState, + clawbot_outbound_tx: Option>, + clawbot_provider_tasks: HashMap>, + clawbot_thread_history_cells: HashMap>>, + clawbot_pending_turns: HashMap>, thread_event_channels: HashMap, thread_event_listener_tasks: HashMap>, @@ -1011,6 +1084,7 @@ impl App { feedback_audience: self.feedback_audience, model: Some(self.chat_widget.current_model().to_string()), startup_tooltip_override: None, + display_preferences: self.display_preferences.clone(), status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), @@ -1037,6 +1111,7 @@ impl App { self.apply_runtime_policy_overrides(&mut config); self.config = config; self.chat_widget.sync_plugin_mentions_config(&self.config); + self.display_preferences.sync_from_config(&self.config); Ok(()) } @@ -1881,12 +1956,38 @@ impl App { async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> { let refresh_pending_thread_approvals = ThreadEventStore::event_can_change_pending_thread_approvals(&event); + if self + .maybe_auto_respond_to_clawbot_interactive_event(thread_id, &event.msg) + .await + .map_err(|error| color_eyre::eyre::eyre!("{error}"))? + { + return Ok(()); + } + enum PrimaryLoopEvent { + TurnComplete(Option), + Error, + } + let primary_loop_event = if Some(thread_id) == self.primary_thread_id { + match &event.msg { + EventMsg::TurnComplete(turn_complete) => Some(PrimaryLoopEvent::TurnComplete( + turn_complete.last_agent_message.clone(), + )), + EventMsg::Error(_) => Some(PrimaryLoopEvent::Error), + _ => None, + } + } else { + None + }; let inactive_interactive_request = if self.active_thread_id != Some(thread_id) { self.interactive_request_for_thread_event(thread_id, &event) .await } else { None }; + let clawbot_terminal_event = match &event.msg { + EventMsg::TurnComplete(_) | EventMsg::Error(_) => Some(event.clone()), + _ => None, + }; let (sender, store) = { let channel = self.ensure_thread_channel(thread_id); (channel.sender.clone(), Arc::clone(&channel.store)) @@ -1929,6 +2030,20 @@ impl App { if refresh_pending_thread_approvals { self.refresh_pending_thread_approvals().await; } + if let Some(event) = clawbot_terminal_event { + self.handle_clawbot_thread_terminal_event(thread_id, &event.msg) + .await; + } + match primary_loop_event { + Some(PrimaryLoopEvent::TurnComplete(last_agent_message)) => { + self.handle_primary_thread_turn_complete_for_loops(last_agent_message) + .await; + } + Some(PrimaryLoopEvent::Error) => { + self.note_primary_thread_error_for_loops().await; + } + None => {} + } Ok(()) } @@ -1954,6 +2069,7 @@ impl App { let thread_id = session.session_id; self.primary_thread_id = Some(thread_id); self.primary_session_configured = Some(session.clone()); + self.ensure_loop_timers_loaded(); self.upsert_agent_picker_thread( thread_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, @@ -2070,196 +2186,1178 @@ impl App { self.sync_active_agent_label(); } - /// Marks a cached picker thread closed and recomputes the contextual footer label. - /// - /// Closing a thread is not the same as removing it: users can still inspect finished agent - /// transcripts, and the stable next/previous traversal order should not collapse around them. - fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) { - self.agent_navigation.mark_closed(thread_id); - self.sync_active_agent_label(); + fn open_control_panel(&mut self) { + let items = vec![ + SelectionItem { + name: "Accounts".to_string(), + description: None, + selected_description: Some("Inspect the managed multi-account pool.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenAccountsPanel))], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Sessions".to_string(), + description: None, + selected_description: Some("Resume or switch saved chats.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenResumePickerAll))], + dismiss_on_select: false, + ..Default::default() + }, + control_panel_clawbot_item(), + control_panel_thread_item(), + SelectionItem { + name: "Loop Manager".to_string(), + description: None, + selected_description: Some( + "Manage workspace-local `/loop` scheduled prompts.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenLoopTimersPanel))], + dismiss_on_select: false, + ..Default::default() + }, + control_panel_show_hide_item(), + ]; + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-control-panel"), + title: Some("Control Panel".to_string()), + subtitle: Some(format!("{} features available.", items.len())), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); } - async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> { - if self.active_thread_id == Some(thread_id) { - return Ok(()); + fn thread_panel_params(&self, initial_selected_idx: Option) -> SelectionViewParams { + let items = thread_panel_items(); + SelectionViewParams { + view_id: Some("fork-thread-panel"), + title: Some("Thread".to_string()), + subtitle: Some(format!("{} actions available.", items.len())), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() } + } - let live_thread = match self.server.get_thread(thread_id).await { - Ok(thread) => Some(thread), - Err(err) => { - if self.thread_event_channels.contains_key(&thread_id) { - self.mark_agent_picker_thread_closed(thread_id); - None + fn open_thread_panel(&mut self) { + let view_id = "fork-thread-panel"; + let initial_selected_idx = self.chat_widget.selected_index_for_active_view(view_id); + if !self.chat_widget.replace_selection_view_if_active( + view_id, + self.thread_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.thread_panel_params(initial_selected_idx)); + } + } + + fn append_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() { + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); } else { - self.chat_widget.add_error_message(format!( - "Failed to attach to agent thread {thread_id}: {err}" - )); - return Ok(()); + self.has_emitted_history_lines = true; } } - }; - let is_replay_only = live_thread.is_none(); - - let previous_thread_id = self.active_thread_id; - self.store_active_thread_receiver().await; - self.active_thread_id = None; - let Some((receiver, snapshot)) = self.activate_thread_for_replay(thread_id).await else { - self.chat_widget - .add_error_message(format!("Agent thread {thread_id} is already active.")); - if let Some(previous_thread_id) = previous_thread_id { - self.activate_thread_channel(previous_thread_id).await; + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); } - return Ok(()); - }; - - self.active_thread_id = Some(thread_id); - self.active_thread_rx = Some(receiver); - - let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - let codex_op_tx = if let Some(thread) = live_thread { - crate::chatwidget::spawn_op_forwarder(thread) - } else { - let (tx, _rx) = unbounded_channel(); - tx - }; - self.replace_chat_widget(ChatWidget::new_with_op_sender(init, codex_op_tx)); - - self.reset_for_thread_switch(tui)?; - self.replay_thread_snapshot(snapshot, !is_replay_only); - if is_replay_only { - self.chat_widget.add_info_message( - format!("Agent thread {thread_id} is closed. Replaying saved transcript."), - /*hint*/ None, - ); } - self.drain_active_thread_events(tui).await?; - self.refresh_pending_thread_approvals().await; - - Ok(()) - } - - fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { - self.overlay = None; - self.transcript_cells.clear(); - self.deferred_history_lines.clear(); - self.has_emitted_history_lines = false; - self.backtrack = BacktrackState::default(); - self.backtrack_render_pending = false; - tui.terminal.clear_scrollback()?; - tui.terminal.clear()?; - Ok(()) } - fn reset_thread_event_state(&mut self) { - self.abort_all_thread_event_listeners(); - self.thread_event_channels.clear(); - self.agent_navigation.clear(); - self.active_thread_id = None; - self.active_thread_rx = None; - self.primary_thread_id = None; - self.pending_primary_events.clear(); - self.chat_widget.set_pending_thread_approvals(Vec::new()); - self.sync_active_agent_label(); + fn display_preferences_panel_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + SelectionViewParams { + view_id: Some("fork-display-preferences-panel"), + title: Some("Show / Hide UI".to_string()), + subtitle: Some("These toggles only affect this TUI session.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + footer_note: Some( + "Model context and persisted rollout history are unchanged." + .dim() + .into(), + ), + items: display_preferences_items(&self.display_preferences), + initial_selected_idx, + ..Default::default() + } } - fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { - let previous_terminal_title = self.chat_widget.last_terminal_title.take(); - if chat_widget.last_terminal_title.is_none() { - chat_widget.last_terminal_title = previous_terminal_title; + fn open_display_preferences_panel(&mut self) { + let view_id = "fork-display-preferences-panel"; + let initial_selected_idx = self.chat_widget.selected_index_for_active_view(view_id); + if !self.chat_widget.replace_selection_view_if_active( + view_id, + self.display_preferences_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.display_preferences_panel_params(initial_selected_idx)); } - self.chat_widget = chat_widget; - self.sync_active_agent_label(); - self.refresh_status_surfaces(); } - async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) { - // Start a fresh in-memory session while preserving resumability via persisted rollout - // history. - self.refresh_in_memory_config_from_disk_best_effort("starting a new thread") - .await; - let model = self.chat_widget.current_model().to_string(); - let config = self.fresh_session_config(); - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - self.chat_widget.thread_name(), - ); - self.shutdown_current_thread().await; - let report = self - .server - .shutdown_all_threads_bounded(Duration::from_secs(10)) - .await; - if !report.submit_failed.is_empty() || !report.timed_out.is_empty() { - tracing::warn!( - submit_failed = report.submit_failed.len(), - timed_out = report.timed_out.len(), - "failed to close all threads" - ); - } - let init = crate::chatwidget::ChatWidgetInit { - config, - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - // New sessions start without prefilled message content. - initial_user_message: None, - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - feedback_audience: self.feedback_audience, - model: Some(model), - startup_tooltip_override: None, - status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), - session_telemetry: self.session_telemetry.clone(), - terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), + fn open_jump_to_message_panel(&mut self) { + let catalog = build_jump_catalog(&self.transcript_cells); + let subtitle = if catalog.is_empty() { + Some("No committed transcript entries are available yet.".to_string()) + } else { + Some(format!( + "{} committed transcript entr{} available.", + catalog.len(), + if catalog.len() == 1 { + "y is" + } else { + "ies are" + } + )) + }; + let items = if catalog.is_empty() { + vec![SelectionItem { + name: "Nothing to jump to yet".to_string(), + description: Some( + "Send a message or wait for a response before using Jump To Message." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }] + } else { + catalog + .targets + .iter() + .map(|target| { + let cell_index = target.cell_index; + SelectionItem { + name: target.title.clone(), + description: Some(target.preview.clone()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::JumpToTranscriptCell(cell_index)); + })], + dismiss_on_select: true, + search_value: Some(target.search_value()), + ..Default::default() + } + }) + .collect() }; - self.replace_chat_widget(ChatWidget::new(init, self.server.clone())); - self.reset_thread_event_state(); - if let Some(summary) = summary { - let mut lines: Vec> = vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec!["To continue this session, run ".into(), command.cyan()]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - tui.frame_requester().schedule_frame(); - } - fn fresh_session_config(&self) -> Config { - let mut config = self.config.clone(); - config.service_tier = self.chat_widget.current_service_tier(); - config + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-jump-to-message-panel"), + title: Some("Jump To Message".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); } - async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { - let Some(mut rx) = self.active_thread_rx.take() else { - return Ok(()); + fn open_accounts_panel(&mut self) { + let view_id = "fork-accounts-panel"; + let initial_selected_idx = self.chat_widget.selected_index_for_active_view(view_id); + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "{} account(s) configured. Active: {}.", + state.accounts.len(), + state.active_account_id.as_deref().unwrap_or("none") + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), }; - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(event) => self.handle_codex_event_now(event), - Err(TryRecvError::Empty) => break, - Err(TryRecvError::Disconnected) => { - disconnected = true; - break; + match state { + Ok(state) => { + let active_account_id = state.active_account_id.clone(); + let live_usage_summary = self + .chat_widget + .current_live_managed_account_usage_summary(); + for account in &state.accounts { + let mut description_parts = Vec::new(); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + if let Some(plan_label) = &account.plan_label { + description_parts.push(plan_label.clone()); + } + let usage_summary = if active_account_id.as_deref() == Some(account.id.as_str()) + { + live_usage_summary + .clone() + .or_else(|| account.usage_summary()) + } else { + account.usage_summary() + }; + if let Some(usage_summary) = usage_summary { + description_parts.push(usage_summary); + } + if let Some(invalid_reason) = &account.invalid_reason { + description_parts.push(format!("invalid: {invalid_reason}")); + } + if let Some(cooldown_until) = account.cooldown_until { + description_parts.push(format!("cooldown until {cooldown_until}")); + } + + let account_id = account.id.clone(); + let search_value = format!( + "{} {} {}", + account.display_name(), + account.id, + account.masked_email.clone().unwrap_or_default() + ); + items.push(SelectionItem { + name: account.display_name().to_string(), + description: (!description_parts.is_empty()) + .then(|| description_parts.join(" · ")), + is_current: state.active_account_id.as_deref() == Some(account.id.as_str()), + is_disabled: !account.enabled, + disabled_reason: (!account.enabled).then(|| "Disabled".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SetManagedAccountActive(account_id.clone())); + })], + dismiss_on_select: true, + search_value: Some(search_value), + ..Default::default() + }); + } + if !state.accounts.is_empty() { + items.push(SelectionItem { + name: "Refresh Quota".to_string(), + description: Some( + "Fetch the latest quota for the active ChatGPT account.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::RefreshManagedAccountQuota))], + dismiss_on_select: false, + ..Default::default() + }); + items.push(SelectionItem { + name: "Refresh All Quota".to_string(), + description: Some( + "Fetch the latest quota for every managed ChatGPT account.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::RefreshAllManagedAccountsQuota) + })], + dismiss_on_select: false, + ..Default::default() + }); + items.push(SelectionItem { + name: "Rename".to_string(), + description: Some( + "Open alias rename actions for managed accounts.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenManagedAccountRenamePanel) + })], + dismiss_on_select: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Delete".to_string(), + description: Some( + "Open deletion actions for managed accounts.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenManagedAccountDeletePanel) + })], + dismiss_on_select: true, + ..Default::default() + }); } } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } } - if !disconnected { - self.active_thread_rx = Some(rx); - } else { - self.clear_active_thread().await; + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); } - if self.backtrack_render_pending { - tui.frame_requester().schedule_frame(); - } - Ok(()) + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(view_id), + title: Some("Accounts".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(path.display().to_string()), + initial_selected_idx, + items, + ..Default::default() + }); + } + + fn open_managed_account_rename_panel(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "Rename aliases for {} managed account(s).", + state.accounts.len() + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + for account in &state.accounts { + let account_id = account.id.clone(); + let current_alias = account.alias.clone(); + let mut description_parts = Vec::new(); + if let Some(alias) = + (!current_alias.is_empty()).then_some(current_alias.clone()) + { + description_parts.push(format!("alias: {alias}")); + } + description_parts.push(account.id.clone()); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + items.push(SelectionItem { + name: format!("Rename {}", account.display_name()), + description: Some(description_parts.join(" · ")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRenameManagedAccountAliasPrompt { + account_id: account_id.clone(), + current_alias: current_alias.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-account-rename-panel"), + title: Some("Rename".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(path.display().to_string()), + items, + ..Default::default() + }); + } + + fn open_managed_account_delete_panel(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "Delete saved snapshots for {} managed account(s).", + state.accounts.len() + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + let invalid_accounts: Vec<_> = state + .accounts + .iter() + .filter(|account| account.is_invalid()) + .collect(); + let active_invalid_exists = invalid_accounts + .iter() + .any(|account| state.active_account_id.as_deref() == Some(account.id.as_str())); + items.push(SelectionItem { + name: "Delete All Invalid".to_string(), + description: Some(if invalid_accounts.is_empty() { + "No invalid managed accounts are marked yet. Run Refresh All Quota first." + .to_string() + } else { + format!( + "Remove {} invalid managed account snapshot(s).", + invalid_accounts.len() + ) + }), + is_disabled: invalid_accounts.is_empty() || active_invalid_exists, + disabled_reason: if invalid_accounts.is_empty() { + Some("Refresh all accounts before bulk deletion".to_string()) + } else if active_invalid_exists { + Some("Switch away before deleting active invalid accounts".to_string()) + } else { + None + }, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::DeleteAllInvalidManagedAccounts) + })], + dismiss_on_select: true, + ..Default::default() + }); + for account in &state.accounts { + let account_id = account.id.clone(); + let display_name = account.display_name().to_string(); + let is_active = state.active_account_id.as_deref() == Some(account.id.as_str()); + let mut description_parts = Vec::new(); + description_parts.push(account.id.clone()); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + if let Some(invalid_reason) = &account.invalid_reason { + description_parts.push(format!("invalid: {invalid_reason}")); + } + if is_active { + description_parts.push("active".to_string()); + } + items.push(SelectionItem { + name: format!("Delete {display_name}"), + description: Some(description_parts.join(" · ")), + is_disabled: is_active, + disabled_reason: is_active + .then(|| "Switch away before deleting".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenDeleteManagedAccountConfirmation { + account_id: account_id.clone(), + display_name: display_name.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-account-delete-panel"), + title: Some("Delete".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(path.display().to_string()), + items, + ..Default::default() + }); + } + + fn sync_current_auth_into_account_pool( + &self, + store: &AccountPoolStore, + ) -> std::io::Result { + let snapshot = persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + )?; + store.update(|state| { + if let Some(snapshot) = &snapshot { + state.upsert_account(snapshot.profile.clone()); + } + }) + } + + fn accounts_panel_is_active(&self) -> bool { + self.chat_widget + .selected_index_for_active_view("fork-accounts-panel") + .is_some() + } + + fn apply_rate_limit_snapshot_to_accounts(&mut self, snapshot: RateLimitSnapshot) { + self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + if self.accounts_panel_is_active() { + self.open_accounts_panel(); + } + } + + fn refresh_managed_account_quota(&mut self) { + let app_event_tx = self.app_event_tx.clone(); + let auth_manager = Arc::clone(&self.auth_manager); + let base_url = self.config.chatgpt_base_url.clone(); + let store = AccountPoolStore::new(self.config.codex_home.clone()); + + tokio::spawn(async move { + let result = match auth_manager.auth().await { + Some(auth) if auth.is_chatgpt_auth() => match auth.get_account_id() { + Some(account_id) => { + let display_name = match store.load() { + Ok(state) => state + .accounts + .iter() + .find(|account| account.id == account_id) + .map(|account| account.display_name().to_string()) + .unwrap_or_else(|| account_id.clone()), + Err(_) => account_id.clone(), + }; + Ok(vec![ManagedAccountQuotaUpdate { + account_id, + display_name, + outcome: fetch_managed_account_quota(base_url, auth).await, + }]) + } + None => Err("The active managed account is missing an account id.".to_string()), + }, + Some(_) => Err("The active managed account does not use ChatGPT auth.".to_string()), + None => Err("No active managed account is available to refresh.".to_string()), + }; + app_event_tx.send(AppEvent::ManagedAccountsQuotaRefreshed(result)); + }); + } + + fn refresh_all_managed_accounts_quota(&mut self) { + let app_event_tx = self.app_event_tx.clone(); + let base_url = self.config.chatgpt_base_url.clone(); + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let auth_store = ManagedAccountAuthStore::new(self.config.codex_home.clone()); + + tokio::spawn(async move { + let result = match store.load() { + Ok(state) => { + let mut updates = Vec::new(); + for account in state.accounts { + match auth_store.load_account_auth(&account.id) { + Ok(auth) => updates.push(ManagedAccountQuotaUpdate { + account_id: account.id.clone(), + display_name: account.display_name().to_string(), + outcome: fetch_managed_account_quota_from_auth_dot_json( + base_url.clone(), + &auth, + ) + .await, + }), + Err(err) => updates.push(ManagedAccountQuotaUpdate { + account_id: account.id.clone(), + display_name: account.display_name().to_string(), + outcome: ManagedAccountQuotaOutcome::Error(format!( + "failed to load saved auth snapshot: {err}" + )), + }), + } + } + Ok(updates) + } + Err(err) => Err(format!("Failed to load managed account pool: {err}")), + }; + app_event_tx.send(AppEvent::ManagedAccountsQuotaRefreshed(result)); + }); + } + + fn finish_managed_account_quota_refresh( + &mut self, + result: Result, String>, + ) { + match result { + Ok(updates) => { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let active_account_id = store.load().ok().and_then(|state| state.active_account_id); + if let Err(err) = store.update(|state| { + for update in &updates { + match &update.outcome { + ManagedAccountQuotaOutcome::Refreshed(snapshots) => { + for snapshot in snapshots { + self.apply_managed_account_rate_limit_snapshot( + state, + &update.account_id, + snapshot, + ); + } + } + ManagedAccountQuotaOutcome::Invalid(reason) => { + state.set_invalid_reason(&update.account_id, Some(reason.clone())); + } + ManagedAccountQuotaOutcome::Error(_) => {} + } + } + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to persist managed account quota refresh: {err}" + ))); + } + + let mut refreshed_count = 0usize; + let mut invalid_count = 0usize; + for update in updates { + match update.outcome { + ManagedAccountQuotaOutcome::Refreshed(snapshots) => { + refreshed_count += 1; + if active_account_id.as_deref() == Some(update.account_id.as_str()) { + for snapshot in snapshots { + self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + } + } + } + ManagedAccountQuotaOutcome::Invalid(reason) => { + invalid_count += 1; + self.chat_widget + .add_to_history(history_cell::new_warning_event(format!( + "Managed account {} is invalid: {reason}", + update.display_name + ))); + } + ManagedAccountQuotaOutcome::Error(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to refresh managed account quota for {}: {err}", + update.display_name + ))); + } + } + } + if refreshed_count > 0 || invalid_count > 0 { + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!( + "Managed account quota refresh complete: {refreshed_count} updated, {invalid_count} invalid." + ), + /*hint*/ None, + )); + } + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to refresh managed account quota: {err}" + ))); + } + } + self.open_accounts_panel(); + } + + fn delete_all_invalid_managed_accounts(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let state = match store.load() { + Ok(state) => state, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to load managed account pool: {err}" + ))); + return; + } + }; + + let active_invalid_exists = state.accounts.iter().any(|account| { + account.is_invalid() && state.active_account_id.as_deref() == Some(account.id.as_str()) + }); + if active_invalid_exists { + self.chat_widget + .add_to_history(history_cell::new_error_event( + "Switch to another managed account before deleting active invalid accounts." + .to_string(), + )); + self.open_managed_account_delete_panel(); + return; + } + + let invalid_accounts: Vec<_> = state + .accounts + .iter() + .filter(|account| account.is_invalid()) + .map(|account| (account.id.clone(), account.display_name().to_string())) + .collect(); + if invalid_accounts.is_empty() { + self.chat_widget + .add_to_history(history_cell::new_info_event( + "No invalid managed accounts to delete.".to_string(), + /*hint*/ None, + )); + self.open_managed_account_delete_panel(); + return; + } + + let auth_store = ManagedAccountAuthStore::new(self.config.codex_home.clone()); + let mut deleted_account_ids = Vec::new(); + let mut deleted_display_names = Vec::new(); + for (account_id, display_name) in invalid_accounts { + match auth_store.delete_account_auth(&account_id) { + Ok(()) => { + deleted_account_ids.push(account_id); + deleted_display_names.push(display_name); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to remove managed account auth snapshot for {display_name}: {err}" + ))); + } + } + } + + if deleted_account_ids.is_empty() { + self.open_managed_account_delete_panel(); + return; + } + + if let Err(err) = store.update(|state| { + for account_id in &deleted_account_ids { + state.remove_account(account_id); + } + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to delete invalid managed accounts: {err}" + ))); + return; + } + + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!( + "Deleted invalid managed accounts: {}.", + deleted_display_names.join(", ") + ), + /*hint*/ None, + )); + self.open_managed_account_delete_panel(); + } + + fn set_managed_account_active(&mut self, account_id: String) { + if let Err(err) = persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to snapshot current managed account auth: {err}" + ))); + return; + } + if let Err(err) = activate_managed_account( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + &account_id, + ) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to activate managed account auth: {err}" + ))); + return; + } + self.auth_manager.reload(); + + let store = AccountPoolStore::new(self.config.codex_home.clone()); + if let Err(err) = store.update(|state| { + state.set_active_account(&account_id, Utc::now().timestamp()); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to update active managed account: {err}" + ))); + return; + } + + self.open_accounts_panel(); + } + + fn save_managed_account_alias(&mut self, account_id: String, alias: String) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + if let Err(err) = store.update(|state| { + state.rename_account_alias(&account_id, alias.clone()); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to save managed account alias: {err}" + ))); + return; + } + + self.open_managed_account_rename_panel(); + } + + fn open_managed_account_delete_confirmation( + &mut self, + account_id: String, + display_name: String, + ) { + let auth_store = ManagedAccountAuthStore::new(self.config.codex_home.clone()); + let auth_path = auth_store.account_auth_path(&account_id); + let confirm_account_id = account_id; + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-account-delete-confirm-panel"), + title: Some("Delete Account".to_string()), + subtitle: Some(format!( + "Delete saved managed-account data for {display_name}?" + )), + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(auth_path.display().to_string()), + items: vec![ + SelectionItem { + name: format!("Delete {display_name}"), + description: Some( + "Remove its saved auth snapshot and alias from the managed pool." + .to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::DeleteManagedAccount(confirm_account_id.clone())); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Return to the delete menu.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenManagedAccountDeletePanel); + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + } + + fn delete_managed_account(&mut self, account_id: String) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let state = match store.load() { + Ok(state) => state, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to load managed account pool: {err}" + ))); + return; + } + }; + + if state.active_account_id.as_deref() == Some(account_id.as_str()) { + self.chat_widget + .add_to_history(history_cell::new_error_event( + "Switch to another managed account before deleting the active one.".to_string(), + )); + self.open_managed_account_delete_panel(); + return; + } + + let Some(display_name) = state + .accounts + .iter() + .find(|account| account.id == account_id) + .map(|account| account.display_name().to_string()) + else { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Managed account {account_id} was not found." + ))); + self.open_managed_account_delete_panel(); + return; + }; + + let auth_store = ManagedAccountAuthStore::new(self.config.codex_home.clone()); + if let Err(err) = auth_store.delete_account_auth(&account_id) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to remove managed account auth snapshot: {err}" + ))); + return; + } + + if let Err(err) = store.update(|state| { + state.remove_account(&account_id); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to delete managed account: {err}" + ))); + return; + } + + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!("Deleted managed account {display_name}."), + /*hint*/ None, + )); + self.open_managed_account_delete_panel(); + } + + fn apply_managed_account_rate_limit_snapshot( + &self, + state: &mut codex_accounts::AccountPoolState, + account_id: &str, + snapshot: &RateLimitSnapshot, + ) { + let plan_label = snapshot + .plan_type + .map(|plan_type| format!("{plan_type:?}").to_ascii_lowercase()); + state.set_plan_label(account_id, plan_label); + state.set_invalid_reason(account_id, /*invalid_reason*/ None); + state.apply_rate_limit_snapshot(account_id, &account_rate_limit_snapshot(snapshot)); + } + + fn jump_to_transcript_cell(&mut self, tui: &mut tui::Tui, cell_index: usize) { + if cell_index >= self.transcript_cells.len() { + self.chat_widget.add_error_message(format!( + "Transcript entry {cell_index} is no longer available." + )); + return; + } + + let _ = tui.enter_alt_screen(); + let mut overlay = Overlay::new_transcript(self.transcript_cells.clone()); + if let Overlay::Transcript(transcript) = &mut overlay { + transcript.set_highlight_cell(Some(cell_index)); + } + self.overlay = Some(overlay); + tui.frame_requester().schedule_frame(); + } + + /// Marks a cached picker thread closed and recomputes the contextual footer label. + /// + /// Closing a thread is not the same as removing it: users can still inspect finished agent + /// transcripts, and the stable next/previous traversal order should not collapse around them. + fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) { + self.agent_navigation.mark_closed(thread_id); + self.sync_active_agent_label(); + } + + async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> { + if self.active_thread_id == Some(thread_id) { + return Ok(()); + } + + let live_thread = match self.server.get_thread(thread_id).await { + Ok(thread) => Some(thread), + Err(err) => { + if self.thread_event_channels.contains_key(&thread_id) { + self.mark_agent_picker_thread_closed(thread_id); + None + } else { + self.chat_widget.add_error_message(format!( + "Failed to attach to agent thread {thread_id}: {err}" + )); + return Ok(()); + } + } + }; + let is_replay_only = live_thread.is_none(); + + let previous_thread_id = self.active_thread_id; + self.store_active_thread_receiver().await; + self.active_thread_id = None; + let Some((receiver, snapshot)) = self.activate_thread_for_replay(thread_id).await else { + self.chat_widget + .add_error_message(format!("Agent thread {thread_id} is already active.")); + if let Some(previous_thread_id) = previous_thread_id { + self.activate_thread_channel(previous_thread_id).await; + } + return Ok(()); + }; + + self.active_thread_id = Some(thread_id); + self.active_thread_rx = Some(receiver); + + let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + let codex_op_tx = if let Some(thread) = live_thread { + crate::chatwidget::spawn_op_forwarder(thread) + } else { + let (tx, _rx) = unbounded_channel(); + tx + }; + self.replace_chat_widget(ChatWidget::new_with_op_sender(init, codex_op_tx)); + + self.reset_for_thread_switch(tui)?; + self.replay_thread_snapshot(snapshot, !is_replay_only); + if is_replay_only { + self.chat_widget.add_info_message( + format!("Agent thread {thread_id} is closed. Replaying saved transcript."), + /*hint*/ None, + ); + } + self.drain_active_thread_events(tui).await?; + self.refresh_pending_thread_approvals().await; + + Ok(()) + } + + fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.overlay = None; + self.transcript_cells.clear(); + self.deferred_history_lines.clear(); + self.has_emitted_history_lines = false; + self.backtrack = BacktrackState::default(); + self.backtrack_render_pending = false; + tui.terminal.clear_scrollback()?; + tui.terminal.clear()?; + Ok(()) + } + + fn reset_thread_event_state(&mut self) { + self.abort_all_thread_event_listeners(); + self.thread_event_channels.clear(); + self.agent_navigation.clear(); + self.active_thread_id = None; + self.active_thread_rx = None; + self.primary_thread_id = None; + self.primary_session_configured = None; + self.pending_primary_events.clear(); + self.loop_timers.thread_history_cells.clear(); + self.loop_timers.after_turn_scheduler.clear(); + self.clawbot_thread_history_cells.clear(); + self.clawbot_pending_turns.clear(); + self.chat_widget.set_pending_thread_approvals(Vec::new()); + self.sync_active_agent_label(); + } + + fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + let previous_terminal_title = self.chat_widget.last_terminal_title.take(); + if chat_widget.last_terminal_title.is_none() { + chat_widget.last_terminal_title = previous_terminal_title; + } + self.chat_widget = chat_widget; + self.sync_active_agent_label(); + self.refresh_status_surfaces(); + } + + async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) { + // Start a fresh in-memory session while preserving resumability via persisted rollout + // history. + self.refresh_in_memory_config_from_disk_best_effort("starting a new thread") + .await; + let model = self.chat_widget.current_model().to_string(); + let config = self.fresh_session_config(); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + self.shutdown_current_thread().await; + let report = self + .server + .shutdown_all_threads_bounded(Duration::from_secs(10)) + .await; + if !report.submit_failed.is_empty() || !report.timed_out.is_empty() { + tracing::warn!( + submit_failed = report.submit_failed.len(), + timed_out = report.timed_out.len(), + "failed to close all threads" + ); + } + let init = crate::chatwidget::ChatWidgetInit { + config, + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + // New sessions start without prefilled message content. + initial_user_message: None, + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + feedback_audience: self.feedback_audience, + model: Some(model), + startup_tooltip_override: None, + display_preferences: self.display_preferences.clone(), + status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), + session_telemetry: self.session_telemetry.clone(), + terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), + }; + self.replace_chat_widget(ChatWidget::new(init, self.server.clone())); + self.reset_thread_event_state(); + if let Some(summary) = summary { + let mut lines: Vec> = vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec!["To continue this session, run ".into(), command.cyan()]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + tui.frame_requester().schedule_frame(); + } + + fn fresh_session_config(&self) -> Config { + let mut config = self.config.clone(); + config.service_tier = self.chat_widget.current_service_tier(); + config + } + + async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { + let Some(mut rx) = self.active_thread_rx.take() else { + return Ok(()); + }; + + let mut disconnected = false; + loop { + match rx.try_recv() { + Ok(event) => self.handle_codex_event_now(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + + if !disconnected { + self.active_thread_rx = Some(rx); + } else { + self.clear_active_thread().await; + } + + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); + } + Ok(()) } /// Returns `(closed_thread_id, primary_thread_id)` when a non-primary active @@ -2300,6 +3398,8 @@ impl App { for event in snapshot.events { self.handle_codex_event_replay(event); } + self.replay_loop_history_cells_for_active_thread(); + self.replay_clawbot_history_cells_for_active_thread(); self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); if resume_restored_queue { @@ -2350,6 +3450,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); emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await; @@ -2429,6 +3530,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 = @@ -2456,6 +3558,7 @@ impl App { feedback_audience, model: Some(model.clone()), startup_tooltip_override, + display_preferences: display_preferences.clone(), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned @@ -2494,6 +3597,7 @@ impl App { feedback_audience, model: config.model.clone(), startup_tooltip_override: None, + display_preferences: display_preferences.clone(), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned @@ -2538,6 +3642,7 @@ impl App { feedback_audience, model: config.model.clone(), startup_tooltip_override: None, + display_preferences: display_preferences.clone(), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned @@ -2569,6 +3674,7 @@ impl App { harness_overrides, runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, + display_preferences, file_search, enhanced_keys_supported, transcript_cells: Vec::new(), @@ -2579,6 +3685,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, @@ -2586,6 +3693,12 @@ impl App { suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), + btw_session: None, + loop_timers: LoopTimersState::default(), + clawbot_outbound_tx: None, + clawbot_provider_tasks: HashMap::new(), + clawbot_thread_history_cells: HashMap::new(), + clawbot_pending_turns: HashMap::new(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -2627,6 +3740,11 @@ impl App { } } + if let Err(error) = app.bootstrap_clawbot_runtime().await { + app.chat_widget + .add_error_message(format!("Failed to start clawbot runtime: {error}")); + } + let tui_events = tui.event_stream(); tokio::pin!(tui_events); @@ -2741,6 +3859,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, }) } @@ -2808,6 +3927,376 @@ impl App { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { + AppEvent::OpenControlPanel => { + self.open_control_panel(); + } + AppEvent::OpenDisplayPreferencesPanel => { + self.open_display_preferences_panel(); + } + AppEvent::StartBtwDiscussion { prompt } => { + self.start_btw_discussion(prompt).await; + } + AppEvent::BtwCompleted { thread_id, result } => { + self.finish_btw_discussion(thread_id, result); + } + AppEvent::BtwInsertSummary => { + self.insert_btw_summary(); + } + AppEvent::BtwInsertFull => { + self.insert_btw_full(); + } + AppEvent::BtwDiscard => { + self.discard_btw_session(); + } + AppEvent::OpenAccountsPanel => { + self.open_accounts_panel(); + } + AppEvent::OpenClawbotPanel => { + self.open_clawbot_panel(); + } + AppEvent::OpenClawbotSessionsPanel => { + self.open_clawbot_sessions_panel(); + } + AppEvent::OpenClawbotConfigPanel => { + self.open_clawbot_config_panel(); + } + AppEvent::OpenClawbotManualBindPrompt => { + self.open_clawbot_manual_bind_prompt(); + } + AppEvent::OpenClawbotSessionActions { session } => { + self.open_clawbot_session_actions(session); + } + AppEvent::OpenClawbotFeishuConfigPrompt { field } => { + self.open_clawbot_feishu_config_prompt(field) + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::SaveClawbotFeishuConfigValue { field, value } => { + self.save_clawbot_feishu_config_value(field, value) + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::SaveClawbotManualBindSessionId { session_id } => { + self.save_clawbot_manual_bind_session_id(session_id) + .await + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::ClawbotSetTurnMode { mode } => { + self.save_clawbot_turn_mode(mode) + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::ClawbotConnectCurrentThread { session } => { + self.clawbot_connect_current_thread(session) + .await + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::ClawbotDisconnect { session } => { + self.clawbot_disconnect(session) + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::ClawbotFlushCachedMessages { session } => { + self.clawbot_flush_cached_messages(session) + .await + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::ClawbotRetryConnection { provider } => { + self.clawbot_retry_connection(provider) + .await + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::ClawbotScanSessions { provider } => { + self.clawbot_scan_sessions(provider) + .await + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::ClawbotClearSessions { provider } => { + self.clawbot_clear_sessions(provider) + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::ClawbotProviderEvent { event } => { + self.handle_clawbot_provider_event(*event) + .await + .map_err(|error| color_eyre::eyre::eyre!("{error}"))?; + } + AppEvent::OpenThreadPanel => { + self.open_thread_panel(); + } + AppEvent::OpenLoopTimersPanel => { + self.open_loop_timers_panel(); + } + AppEvent::OpenLoopTriggerQueuesPanel => { + self.open_loop_trigger_queues_panel(); + } + AppEvent::OpenLoopTriggerQueuePhase { phase } => { + self.open_loop_trigger_queue_phase_panel(phase); + } + AppEvent::OpenLoopTriggerQueueEntryActions { + phase, + loop_id, + binding_id, + } => { + self.open_loop_trigger_queue_entry_actions(phase, loop_id, binding_id); + } + AppEvent::MoveLoopTriggerQueueEntry { + phase, + loop_id, + binding_id, + move_up, + } => { + self.move_loop_trigger_queue_entry(phase, loop_id, binding_id, move_up); + } + AppEvent::OpenLoopExecutionPanel { timer_id } => { + self.open_loop_execution_panel(timer_id); + } + AppEvent::OpenJumpToMessagePanel => { + self.open_jump_to_message_panel(); + } + AppEvent::CreateLoopTimer { spec } => { + self.create_loop_timer(spec); + } + AppEvent::OpenCreateLoopTimerMenu => { + self.open_create_loop_timer_menu(); + } + AppEvent::StartCreateLoopDraft { context_mode } => { + self.start_loop_create_draft(context_mode); + } + AppEvent::SaveCreateLoopId { id } => { + self.save_create_loop_id(id); + } + AppEvent::SaveCreateLoopPrompt { prompt } => { + self.save_create_loop_prompt(prompt); + } + AppEvent::OpenCreateLoopDraftTriggerMenu => { + self.open_create_loop_draft_trigger_menu(); + } + AppEvent::OpenCreateLoopTimerSchedulePrompt => { + self.chat_widget.open_create_loop_schedule_prompt(); + } + AppEvent::SaveCreateLoopTimerSchedule { schedule } => { + self.save_create_loop_timer_schedule(schedule); + } + AppEvent::SaveCreateLoopBeforeTurnTrigger => { + self.save_create_loop_before_turn_trigger(); + } + AppEvent::SaveCreateLoopAfterTurnTrigger => { + self.save_create_loop_after_turn_trigger(); + } + AppEvent::OpenCreateLoopDraftResponseMode => { + self.open_create_loop_response_mode_menu(); + } + AppEvent::SaveCreateLoopResponseMode { response_mode } => { + self.save_create_loop_response_mode(response_mode); + } + AppEvent::SaveCreateLoopSecurityMode { security_mode } => { + self.save_create_loop_security_mode(security_mode); + } + AppEvent::SaveCreateLoopWritableRoots { writable_roots } => { + self.save_create_loop_writable_roots(writable_roots); + } + AppEvent::TriggerLoopTimer { + timer_id, + scheduled_for_unix_seconds, + source, + } => { + let cells = self + .trigger_loop_timer(timer_id, scheduled_for_unix_seconds, source) + .await; + for cell in cells { + self.append_visible_history_cell(tui, cell); + } + self.refresh_status_surfaces(); + } + AppEvent::OpenLoopTimerActions { timer_id } => { + self.open_loop_timer_actions(timer_id); + } + AppEvent::OpenLoopTimerTriggers { timer_id } => { + self.open_loop_timer_triggers_panel(timer_id); + } + AppEvent::OpenCreateLoopTriggerMenu { timer_id } => { + self.open_create_loop_trigger_menu(timer_id); + } + AppEvent::AddLoopBeforeTurnTrigger { timer_id } => { + self.add_loop_trigger(timer_id, codex_loop::LoopTriggerKind::BeforeTurn); + } + AppEvent::AddLoopAfterTurnTrigger { timer_id } => { + self.add_loop_trigger(timer_id, codex_loop::LoopTriggerKind::AfterTurn); + } + AppEvent::OpenCreateLoopTimerTriggerSchedule { timer_id } => { + self.open_new_loop_timer_trigger_schedule_editor(timer_id); + } + AppEvent::SaveNewLoopTimerTriggerSchedule { timer_id, schedule } => { + self.save_new_loop_timer_trigger_schedule(timer_id, schedule); + } + AppEvent::OpenEditLoopTriggerBindingSchedule { + timer_id, + binding_id, + } => { + self.open_loop_trigger_binding_schedule_editor(timer_id, binding_id); + } + AppEvent::OpenLoopTriggerBindingActions { + timer_id, + binding_id, + } => { + self.open_loop_trigger_binding_actions(timer_id, binding_id); + } + AppEvent::SaveLoopTriggerBindingSchedule { + timer_id, + binding_id, + schedule, + } => { + self.save_loop_trigger_binding_schedule(timer_id, binding_id, schedule); + } + AppEvent::EnableLoopTriggerBinding { + timer_id, + binding_id, + } => { + self.set_loop_trigger_binding_enabled(timer_id, binding_id, /*enabled*/ true); + } + AppEvent::DisableLoopTriggerBinding { + timer_id, + binding_id, + } => { + self.set_loop_trigger_binding_enabled(timer_id, binding_id, /*enabled*/ false); + } + AppEvent::DeleteLoopTriggerBinding { + timer_id, + binding_id, + } => { + self.delete_loop_trigger_binding(timer_id, binding_id); + } + AppEvent::OpenEditLoopTimerPrompt { timer_id } => { + self.open_loop_timer_prompt_editor(timer_id); + } + AppEvent::OpenEditLoopTimerAction { timer_id } => { + self.open_loop_timer_action_editor(timer_id); + } + AppEvent::OpenEditLoopTimerContextMode { timer_id } => { + self.open_loop_timer_context_mode_menu(timer_id); + } + AppEvent::OpenEditLoopTimerResponseMode { timer_id } => { + self.open_loop_timer_response_mode_menu(timer_id); + } + AppEvent::OpenEditLoopTimerSecurityMode { timer_id } => { + self.open_loop_timer_security_mode_menu(timer_id); + } + AppEvent::OpenEditLoopWritableRoots { timer_id } => { + self.open_loop_writable_roots_editor(timer_id); + } + AppEvent::OpenEditLoopTimerCwd { timer_id } => { + self.open_loop_timer_cwd_editor(timer_id); + } + AppEvent::SaveLoopTimerPrompt { timer_id, prompt } => { + self.save_loop_timer_prompt(timer_id, prompt); + } + AppEvent::SaveLoopTimerAction { timer_id, action } => { + self.save_loop_timer_action(timer_id, action); + } + AppEvent::SaveLoopTimerContextMode { + timer_id, + context_mode, + } => { + self.save_loop_timer_context_mode(timer_id, context_mode); + } + AppEvent::SaveLoopTimerResponseMode { + timer_id, + response_mode, + } => { + self.save_loop_timer_response_mode(timer_id, response_mode); + } + AppEvent::SaveLoopTimerSecurityMode { + timer_id, + security_mode, + } => { + self.save_loop_timer_security_mode(timer_id, security_mode); + } + AppEvent::SaveLoopWritableRoots { + timer_id, + writable_roots, + } => { + self.save_loop_writable_roots(timer_id, writable_roots); + } + AppEvent::SaveLoopTimerCwd { timer_id, cwd } => { + self.save_loop_timer_cwd(timer_id, cwd); + } + AppEvent::ResetLoopTimerCwd { timer_id } => { + self.reset_loop_timer_cwd(timer_id); + } + AppEvent::ResetLoopWritableRoots { timer_id } => { + self.reset_loop_writable_roots(timer_id); + } + AppEvent::EnableLoopTimer { timer_id } => { + self.set_loop_timer_enabled(timer_id, /*enabled*/ true); + } + AppEvent::DisableLoopTimer { timer_id } => { + self.set_loop_timer_enabled(timer_id, /*enabled*/ false); + } + AppEvent::DeleteLoopTimer { timer_id } => { + self.delete_loop_timer(timer_id); + } + AppEvent::LoopTimerCompleted { + timer_id, + prompt, + result, + } => { + let completion = self.finish_loop_timer(timer_id, prompt, result); + for cell in completion.cells { + self.append_visible_history_cell(tui, cell); + } + if let Some(followup_user_turn) = completion.followup_user_turn { + let _ = self + .submit_loop_user_message_to_primary(followup_user_turn) + .await; + } + self.refresh_status_surfaces(); + } + AppEvent::PrimaryAfterTurnRoundCompleted { result } => { + self.finish_primary_after_turn_round(result.map(|result| *result)) + .await; + self.refresh_status_surfaces(); + } + AppEvent::PrimaryAfterTurnRoundProgress { loop_label } => { + self.note_primary_after_turn_round_progress(loop_label); + self.refresh_status_surfaces(); + } + AppEvent::UndoLastUserMessage => { + self.undo_last_user_message(); + tui.frame_requester().schedule_frame(); + } + AppEvent::OpenManagedAccountRenamePanel => { + self.open_managed_account_rename_panel(); + } + AppEvent::OpenManagedAccountDeletePanel => { + self.open_managed_account_delete_panel(); + } + AppEvent::RefreshManagedAccountQuota => { + self.refresh_managed_account_quota(); + } + AppEvent::RefreshAllManagedAccountsQuota => { + self.refresh_all_managed_accounts_quota(); + } + AppEvent::SetManagedAccountActive(account_id) => { + self.set_managed_account_active(account_id); + } + AppEvent::OpenRenameManagedAccountAliasPrompt { + account_id, + current_alias, + } => { + self.chat_widget + .open_managed_account_alias_prompt(account_id, current_alias); + } + AppEvent::OpenDeleteManagedAccountConfirmation { + account_id, + display_name, + } => { + self.open_managed_account_delete_confirmation(account_id, display_name); + } + AppEvent::SaveManagedAccountAlias { account_id, alias } => { + self.save_managed_account_alias(account_id, alias); + } + AppEvent::DeleteManagedAccount(account_id) => { + self.delete_managed_account(account_id); + } + AppEvent::DeleteAllInvalidManagedAccounts => { + self.delete_all_invalid_managed_accounts(); + } AppEvent::NewSession => { self.start_fresh_session_with_summary_hint(tui).await; } @@ -2815,14 +4304,115 @@ impl App { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); - self.start_fresh_session_with_summary_hint(tui).await; + self.start_fresh_session_with_summary_hint(tui).await; + } + AppEvent::OpenResumePicker => { + match crate::resume_picker::run_resume_picker( + tui, + &self.config, + /*show_all*/ false, + crate::resume_picker::SessionSourceFilter::InteractiveOnly, + ) + .await? + { + SessionSelection::Resume(target_session) => { + let current_cwd = self.config.cwd.to_path_buf(); + let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + ¤t_cwd, + target_session.thread_id, + &target_session.path, + CwdPromptAction::Resume, + /*allow_prompt*/ true, + ) + .await? + { + crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::ResolveCwdOutcome::Exit => { + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + }; + let mut resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut resume_config); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + match self + .server + .resume_thread_from_rollout( + resume_config.clone(), + target_session.path.clone(), + self.auth_manager.clone(), + /*parent_trace*/ None, + ) + .await + { + Ok(resumed) => { + self.shutdown_current_thread().await; + self.config = resume_config; + tui.set_notification_method(self.config.tui_notification_method); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); + self.replace_chat_widget(ChatWidget::new_from_existing( + init, + resumed.thread, + resumed.session_configured, + )); + self.reset_thread_event_state(); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + let path_display = target_session.path.display(); + self.chat_widget.add_error_message(format!( + "Failed to resume session from {path_display}: {err}" + )); + } + } + } + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::Fork(_) => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); } - AppEvent::OpenResumePicker => { + AppEvent::OpenResumePickerAll => { match crate::resume_picker::run_resume_picker( tui, &self.config, - /*show_all*/ false, - crate::resume_picker::SessionSourceFilter::InteractiveOnly, + /*show_all*/ true, + crate::resume_picker::SessionSourceFilter::IncludeNonInteractive, ) .await? { @@ -2997,29 +4587,7 @@ impl App { } 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; - } - } - if self.overlay.is_some() { - self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); - } - } + self.append_visible_history_cell(tui, cell); } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { @@ -3061,6 +4629,9 @@ impl App { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => { + let (op, loop_cells) = self + .augment_primary_user_turn_with_before_turn_loops(op) + .await; let replay_state_op = ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone()); let submitted = self.chat_widget.submit_op(op); @@ -3068,6 +4639,11 @@ impl App { self.note_active_thread_outbound_op(op).await; self.refresh_pending_thread_approvals().await; } + if submitted { + for cell in loop_cells { + self.append_visible_history_cell(tui, cell); + } + } } AppEvent::SubmitThreadOp { thread_id, op } => { self.submit_op_to_thread(thread_id, op).await; @@ -3154,7 +4730,10 @@ impl App { self.chat_widget.apply_file_search_result(query, matches); } AppEvent::RateLimitSnapshotFetched(snapshot) => { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + self.apply_rate_limit_snapshot_to_accounts(snapshot); + } + AppEvent::ManagedAccountsQuotaRefreshed(result) => { + self.finish_managed_account_quota_refresh(result); } AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); @@ -4015,9 +5594,43 @@ impl App { AppEvent::OpenAgentPicker => { self.open_agent_picker().await; } + AppEvent::ToggleDisplayPreference(key) => { + let enabled = !self.display_preferences.is_enabled(key); + let edit = display_preference_edit(key, enabled); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await + { + Ok(()) => { + self.display_preferences.set_enabled(key, enabled); + set_display_preference_in_config(&mut self.config, key, enabled); + } + Err(err) => { + tracing::error!(error = %err, "failed to persist display preference"); + self.chat_widget + .add_error_message(format!("Failed to save display preference: {err}")); + } + } + self.open_display_preferences_panel(); + tui.frame_requester().schedule_frame(); + } + AppEvent::StopBackgroundLoopRuns => { + let stopped_count = self.stop_active_loop_runs(); + if stopped_count > 0 { + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!("Stopping {stopped_count} background loop run(s)."), + /*hint*/ None, + )); + } + } AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread(tui, thread_id).await?; } + AppEvent::JumpToTranscriptCell(cell_index) => { + self.jump_to_transcript_cell(tui, cell_index); + } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); } @@ -4305,6 +5918,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) + } } } @@ -4520,9 +6137,9 @@ impl App { 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." - .to_string(), - )); + "Cannot open external editor: set $VISUAL or $EDITOR, or install `vim`." + .to_string(), + )); self.reset_external_editor_state(tui); return; } @@ -4577,7 +6194,52 @@ 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, 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 @@ -4696,12 +6358,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); } _ => { @@ -4743,6 +6399,28 @@ impl App { } } +fn account_rate_limit_snapshot(snapshot: &RateLimitSnapshot) -> AccountRateLimitSnapshot { + AccountRateLimitSnapshot { + limit_name: snapshot.limit_name.clone(), + primary: snapshot + .primary + .as_ref() + .map(|window| AccountRateLimitWindow { + used_percent: window.used_percent, + window_minutes: window.window_minutes, + resets_at: window.resets_at, + }), + secondary: snapshot + .secondary + .as_ref() + .map(|window| AccountRateLimitWindow { + used_percent: window.used_percent, + window_minutes: window.window_minutes, + resets_at: window.resets_at, + }), + } +} + impl Drop for App { fn drop(&mut self) { if let Err(err) = self.chat_widget.clear_managed_terminal_title() { @@ -4760,6 +6438,8 @@ mod tests { use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; + use crate::display_preferences::DisplayPreferenceKey; + use crate::display_preferences::display_preference_edit; use crate::file_search::FileSearchManager; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; @@ -4767,6 +6447,17 @@ mod tests { use crate::history_cell::new_session_info; use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; + use codex_clawbot::CachedUnreadMessage; + use codex_clawbot::ClawbotStore; + use codex_clawbot::ClawbotTurnMode; + use codex_clawbot::FeishuConfig; + use codex_clawbot::ProviderKind; + use codex_clawbot::ProviderOutboundAction; + use codex_clawbot::ProviderOutboundTextMessage; + use codex_clawbot::ProviderSession; + use codex_clawbot::ProviderSessionRef; + use codex_clawbot::SessionBinding; + use codex_clawbot::SessionStatus; use codex_core::CodexAuth; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -4780,6 +6471,7 @@ mod tests { use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::protocol::AgentMessageDeltaEvent; use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SandboxPolicy; @@ -6914,10 +8606,13 @@ guardian_approval = true app.chat_widget.config_ref(), app.chat_widget.current_model(), event, - is_first, - None, - None, - false, + crate::history_cell::SessionInfoOptions { + is_first_event: is_first, + tooltip_override: None, + display_preferences: app.display_preferences.clone(), + auth_plan: None, + show_fast_status: false, + }, )) as Arc }; @@ -6936,77 +8631,525 @@ guardian_approval = true ]; app.has_emitted_history_lines = true; - let rendered = app - .clear_ui_header_lines_with_version(80, "") - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() + let rendered = app + .clear_ui_header_lines_with_version(80, "") + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + !rendered.contains("startup tip that used to replay"), + "clear header should not replay startup notices" + ); + assert!( + !rendered.contains("Bracken Ferry"), + "clear header should not replay prior conversation turns" + ); + rendered + } + + #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] + async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { + let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; + assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); + } + + #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] + async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() { + let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; + assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); + } + + #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] + async fn clear_ui_header_shows_fast_status_only_for_gpt54() { + let mut app = make_test_app().await; + app.config.cwd = PathBuf::from("/tmp/project").abs(); + app.chat_widget.set_model("gpt-5.4"); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); + app.chat_widget + .set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast)); + set_chatgpt_auth(&mut app.chat_widget); + + let rendered = app + .clear_ui_header_lines_with_version(80, "") + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert_snapshot!("clear_ui_header_fast_status_gpt54_only", rendered); + } + + #[tokio::test] + async fn display_preferences_panel_toggle_replaces_active_view_so_esc_closes_it() { + let mut app = make_test_app().await; + app.open_display_preferences_panel(); + assert!(app.chat_widget.has_active_view()); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget + .selected_index_for_active_view("fork-display-preferences-panel"), + Some(2) + ); + + app.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + false, + ); + app.open_display_preferences_panel(); + assert_eq!( + app.chat_widget + .selected_index_for_active_view("fork-display-preferences-panel"), + Some(2) + ); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!app.chat_widget.has_active_view()); + } + + #[tokio::test] + async fn display_preferences_panel_esc_returns_to_control_panel() { + let mut app = make_test_app().await; + app.open_control_panel(); + app.open_display_preferences_panel(); + assert_eq!( + app.chat_widget + .selected_index_for_active_view("fork-display-preferences-panel"), + Some(0) + ); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget + .selected_index_for_active_view("fork-control-panel"), + Some(0) + ); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!app.chat_widget.has_active_view()); + } + + #[tokio::test] + async fn clawbot_panel_turn_mode_row_emits_toggle_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.open_clawbot_panel(); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::ClawbotSetTurnMode { + mode: ClawbotTurnMode::NonInteractive, + }) + ); + } + + #[tokio::test] + async fn clawbot_panel_sessions_row_emits_open_sessions_panel_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.open_clawbot_panel(); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::OpenClawbotSessionsPanel) + ); + } + + #[tokio::test] + async fn clawbot_panel_configuration_row_emits_open_config_panel_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.open_clawbot_panel(); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::OpenClawbotConfigPanel) + ); + } + + #[tokio::test] + async fn clawbot_sessions_panel_retry_connection_row_emits_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.open_clawbot_sessions_panel(); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::ClawbotRetryConnection { + provider: ProviderKind::Feishu + }) + ); + } + + #[tokio::test] + async fn clawbot_sessions_panel_manual_bind_row_emits_prompt_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.active_thread_id = Some(ThreadId::new()); + app.open_clawbot_sessions_panel(); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::OpenClawbotManualBindPrompt) + ); + } + + #[tokio::test] + async fn clawbot_sessions_panel_scan_row_emits_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.active_thread_id = Some(ThreadId::new()); + app.open_clawbot_sessions_panel(); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::ClawbotScanSessions { + provider: ProviderKind::Feishu + }) + ); + } + + #[tokio::test] + async fn clawbot_sessions_panel_clear_row_emits_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let tempdir = tempdir().expect("tempdir"); + app.config.cwd = tempdir.path().to_path_buf().abs(); + app.active_thread_id = Some(ThreadId::new()); + + let store = ClawbotStore::new(app.config.cwd.clone()); + store + .save_sessions(&[ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_clear".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 1, + last_message_at: Some(10), + status: SessionStatus::Discovered, + bound_thread_id: None, + }]) + .expect("save sessions"); + store + .save_unread_messages(&[CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_clear".to_string(), + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 11, + }]) + .expect("save unread messages"); + + app.open_clawbot_sessions_panel(); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::ClawbotClearSessions { + provider: ProviderKind::Feishu }) - .collect::>() - .join("\n"); + ); + } - assert!( - !rendered.contains("startup tip that used to replay"), - "clear header should not replay startup notices" + #[tokio::test] + async fn clawbot_session_actions_connect_current_thread_emits_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let tempdir = tempdir().expect("tempdir"); + app.config.cwd = tempdir.path().to_path_buf().abs(); + app.active_thread_id = Some(ThreadId::new()); + + let store = ClawbotStore::new(app.config.cwd.clone()); + store + .save_sessions(&[ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + display_name: Some("Alice".to_string()), + unread_count: 2, + last_message_at: Some(10), + status: SessionStatus::Discovered, + bound_thread_id: None, + }]) + .expect("save sessions"); + + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_1"); + app.open_clawbot_session_actions(session.clone()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::ClawbotConnectCurrentThread { session: emitted }) + if emitted == session ); - assert!( - !rendered.contains("Bracken Ferry"), - "clear header should not replay prior conversation turns" + } + + #[tokio::test] + async fn clawbot_session_actions_flush_cached_messages_emits_event() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let tempdir = tempdir().expect("tempdir"); + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let store = ClawbotStore::new(app.config.cwd.clone()); + store + .save_sessions(&[ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_2".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 3, + last_message_at: Some(10), + status: SessionStatus::Bound, + bound_thread_id: Some("thread_123".to_string()), + }]) + .expect("save sessions"); + + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_2"); + app.open_clawbot_session_actions(session.clone()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::ClawbotFlushCachedMessages { session: emitted }) + if emitted == session ); - rendered + } + + fn persist_clawbot_binding(store: &ClawbotStore, thread_id: ThreadId, session_id: &str) { + store + .save_sessions(&[ProviderSession { + provider: ProviderKind::Feishu, + session_id: session_id.to_string(), + display_name: Some("Alice".to_string()), + unread_count: 0, + last_message_at: Some(10), + status: SessionStatus::Bound, + bound_thread_id: Some(thread_id.to_string()), + }]) + .expect("save sessions"); + store + .save_bindings(&[SessionBinding { + provider: ProviderKind::Feishu, + session_id: session_id.to_string(), + thread_id: thread_id.to_string(), + created_at: 1, + updated_at: 2, + }]) + .expect("save bindings"); } #[tokio::test] - #[cfg_attr( - target_os = "windows", - ignore = "snapshot path rendering differs on Windows" - )] - async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { - let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; - assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); + async fn bound_thread_turn_complete_forwards_final_reply_to_clawbot_session() -> Result<()> { + let mut app = make_test_app().await; + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + let thread_id = ThreadId::new(); + persist_clawbot_binding( + &ClawbotStore::new(app.config.cwd.clone()), + thread_id, + "chat_3", + ); + + let (outbound_tx, mut outbound_rx) = unbounded_channel::(); + app.clawbot_outbound_tx = Some(outbound_tx); + + app.enqueue_thread_event( + thread_id, + Event { + id: "turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Final reply".to_string()), + }), + }, + ) + .await?; + + let outbound = time::timeout(Duration::from_millis(50), outbound_rx.recv()) + .await + .expect("timed out waiting for clawbot outbound reply") + .expect("clawbot outbound channel closed unexpectedly"); + assert_eq!( + outbound, + ProviderOutboundAction::Text(ProviderOutboundTextMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_3"), + text: "Final reply".to_string(), + }) + ); + Ok(()) } #[tokio::test] - #[cfg_attr( - target_os = "windows", - ignore = "snapshot path rendering differs on Windows" - )] - async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() { - let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; - assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); + async fn bound_thread_error_forwards_short_failure_reply_to_clawbot_session() -> Result<()> { + let mut app = make_test_app().await; + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + let thread_id = ThreadId::new(); + persist_clawbot_binding( + &ClawbotStore::new(app.config.cwd.clone()), + thread_id, + "chat_4", + ); + + let (outbound_tx, mut outbound_rx) = unbounded_channel::(); + app.clawbot_outbound_tx = Some(outbound_tx); + + app.enqueue_thread_event( + thread_id, + Event { + id: "turn-error".to_string(), + msg: EventMsg::Error(ErrorEvent { + message: "\nnetwork timeout while calling tool\nstack trace".to_string(), + codex_error_info: None, + }), + }, + ) + .await?; + + let outbound = time::timeout(Duration::from_millis(50), outbound_rx.recv()) + .await + .expect("timed out waiting for clawbot failure reply") + .expect("clawbot outbound channel closed unexpectedly"); + assert_eq!( + outbound, + ProviderOutboundAction::Text(ProviderOutboundTextMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_4"), + text: "Request failed: network timeout while calling tool".to_string(), + }) + ); + Ok(()) } #[tokio::test] - #[cfg_attr( - target_os = "windows", - ignore = "snapshot path rendering differs on Windows" - )] - async fn clear_ui_header_shows_fast_status_only_for_gpt54() { + async fn save_clawbot_feishu_config_value_persists_workspace_config() -> Result<()> { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project").abs(); - app.chat_widget.set_model("gpt-5.4"); - app.chat_widget - .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - app.chat_widget - .set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast)); - set_chatgpt_auth(&mut app.chat_widget); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); - let rendered = app - .clear_ui_header_lines_with_version(80, "") - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() + app.save_clawbot_feishu_config_value( + crate::app_event::ClawbotFeishuConfigField::AppId, + "cli_test".to_string(), + ) + .expect("save app id"); + app.save_clawbot_feishu_config_value( + crate::app_event::ClawbotFeishuConfigField::AppSecret, + "secret_test".to_string(), + ) + .expect("save app secret"); + + assert_eq!( + ClawbotStore::new(app.config.cwd.clone()) + .load_config() + .expect("load clawbot config") + .feishu, + Some(FeishuConfig { + app_id: "cli_test".to_string(), + app_secret: "secret_test".to_string(), + verification_token: None, + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, }) - .collect::>() - .join("\n"); + ); + Ok(()) + } - assert_snapshot!("clear_ui_header_fast_status_gpt54_only", rendered); + #[tokio::test] + async fn save_clawbot_manual_bind_session_id_persists_placeholder_binding() -> Result<()> { + let mut app = make_test_app().await; + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + + app.save_clawbot_manual_bind_session_id("oc_manual_session".to_string()) + .await + .expect("manual bind"); + + let snapshot = ClawbotStore::new(app.config.cwd.clone()) + .load_snapshot() + .expect("load snapshot"); + assert_eq!(snapshot.bindings.len(), 1); + assert_eq!( + snapshot.bindings[0].session_ref(), + ProviderSessionRef::new(ProviderKind::Feishu, "oc_manual_session") + ); + assert_eq!(snapshot.bindings[0].thread_id, thread_id.to_string()); + assert_eq!( + snapshot.sessions[0], + ProviderSession { + provider: ProviderKind::Feishu, + session_id: "oc_manual_session".to_string(), + display_name: None, + unread_count: 0, + last_message_at: None, + status: SessionStatus::Bound, + bound_thread_id: Some(thread_id.to_string()), + } + ); + Ok(()) } async fn make_test_app() -> App { @@ -7040,6 +9183,7 @@ guardian_approval = true harness_overrides: ConfigOverrides::default(), runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, + display_preferences: DisplayPreferences::default(), file_search, transcript_cells: Vec::new(), overlay: None, @@ -7050,6 +9194,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, @@ -7057,6 +9202,12 @@ guardian_approval = true suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), + btw_session: None, + loop_timers: LoopTimersState::default(), + clawbot_outbound_tx: None, + clawbot_provider_tasks: HashMap::new(), + clawbot_thread_history_cells: HashMap::new(), + clawbot_pending_turns: HashMap::new(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -7104,6 +9255,7 @@ guardian_approval = true harness_overrides: ConfigOverrides::default(), runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, + display_preferences: DisplayPreferences::default(), file_search, transcript_cells: Vec::new(), overlay: None, @@ -7114,6 +9266,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, @@ -7121,6 +9274,12 @@ guardian_approval = true suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), + btw_session: None, + loop_timers: LoopTimersState::default(), + clawbot_outbound_tx: None, + clawbot_provider_tasks: HashMap::new(), + clawbot_thread_history_cells: HashMap::new(), + clawbot_pending_turns: HashMap::new(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -7538,6 +9697,33 @@ guardian_approval = true Ok(()) } + #[tokio::test] + async fn refresh_in_memory_config_from_disk_loads_latest_display_preferences() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf(); + + assert!(app.display_preferences.show_tool_results()); + assert!(app.display_preferences.show_exec_commands()); + + ConfigEditsBuilder::new(&app.config.codex_home) + .with_edits([ + display_preference_edit(DisplayPreferenceKey::ToolResults, false), + display_preference_edit(DisplayPreferenceKey::ExecCommands, false), + ]) + .apply() + .await + .expect("persist display preferences"); + + app.refresh_in_memory_config_from_disk().await?; + + assert!(!app.display_preferences.show_tool_results()); + assert!(!app.display_preferences.show_exec_commands()); + assert!(!app.config.tui_display_preferences.show_tool_results); + assert!(!app.config.tui_display_preferences.show_exec_commands); + Ok(()) + } + #[tokio::test] async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() -> Result<()> { @@ -7701,10 +9887,13 @@ guardian_approval = true app.chat_widget.config_ref(), app.chat_widget.current_model(), event, - is_first, - None, - None, - false, + crate::history_cell::SessionInfoOptions { + is_first_event: is_first, + tooltip_override: None, + display_preferences: app.display_preferences.clone(), + auth_plan: None, + show_fast_status: false, + }, )) as Arc }; @@ -7825,6 +10014,116 @@ guardian_approval = true assert_eq!(rollback_turns, Some(1)); } + #[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_u_triggers_undo_last_user_message() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + + app.transcript_cells = vec![Arc::new(UserHistoryCell { + message: "latest".to_string(), + text_elements: Vec::new(), + 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::NONE)), + None + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), "latest"); + + 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_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::NONE)), + None + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + } + #[tokio::test] async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().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..559e64da0 --- /dev/null +++ b/codex-rs/tui/src/app/btw.rs @@ -0,0 +1,495 @@ +use super::App; +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::render::renderable::ColumnRenderable; +use codex_btw::full_insert_text; +use codex_btw::merge_developer_instructions; +use codex_btw::preview_text; +use codex_btw::summarize_message; +use codex_core::CodexThread; +use codex_core::RolloutRecorder; +use codex_protocol::ThreadId; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::TurnItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::path::Path; +use std::sync::Arc; +use tokio::task::JoinHandle; + +const BTW_DISCUSSION_VIEW_ID: &str = "btw_discussion"; +const BTW_CONTEXT_BUDGET_TOKENS: usize = 2_000; + +pub(crate) struct BtwSessionState { + pub(crate) thread_id: ThreadId, + pub(crate) thread: Arc, + pub(crate) listener_handle: JoinHandle<()>, + pub(crate) final_message: Option, +} + +impl App { + pub(crate) async fn start_btw_discussion(&mut self, 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; + } + + let mut btw_config = self.config.clone(); + btw_config.ephemeral = true; + btw_config.include_apply_patch_tool = false; + if let Err(err) = btw_config + .permissions + .approval_policy + .set(AskForApproval::Never) + { + self.chat_widget + .add_error_message(format!("Failed to configure `/btw` approvals: {err}")); + return; + } + if let Err(err) = btw_config + .permissions + .sandbox_policy + .set(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::default(), + network_access: false, + }) + { + self.chat_widget + .add_error_message(format!("Failed to configure `/btw` sandbox: {err}")); + return; + } + btw_config.developer_instructions = Some(merge_developer_instructions( + btw_config.developer_instructions.take(), + )); + + let initial_history = + build_btw_initial_history(self.chat_widget.rollout_path().as_deref()).await; + self.open_btw_loading_panel(); + + let new_thread = match self + .server + .start_thread_with_history_and_source( + btw_config, + initial_history, + SessionSource::SubAgent(SubAgentSource::Other("btw".to_string())), + ) + .await + { + Ok(new_thread) => new_thread, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to start `/btw`: {err}")); + return; + } + }; + + let thread_id = new_thread.thread_id; + let thread = new_thread.thread; + let app_event_tx = self.app_event_tx.clone(); + let listener_thread = Arc::clone(&thread); + let listener_handle = tokio::spawn(async move { + let mut last_agent_message = None; + loop { + match listener_thread.next_event().await { + Ok(event) => match event.msg { + EventMsg::ItemCompleted(item_completed) => { + if let TurnItem::AgentMessage(message) = item_completed.item { + let text = message + .content + .into_iter() + .map(|content| match content { + AgentMessageContent::Text { text } => text, + }) + .collect::(); + if !text.trim().is_empty() { + last_agent_message = Some(text); + } + } + } + EventMsg::TurnComplete(turn_complete) => { + let message = turn_complete.last_agent_message.or(last_agent_message); + app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: message.ok_or_else(|| { + "Temporary discussion finished without a final answer." + .to_string() + }), + }); + break; + } + EventMsg::Error(error) => { + app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err(error.message), + }); + break; + } + EventMsg::ShutdownComplete => { + app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err("Temporary discussion closed before a final answer." + .to_string()), + }); + break; + } + _ => {} + }, + Err(err) => { + app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err(format!("Temporary discussion failed: {err}")), + }); + break; + } + } + } + }); + + self.btw_session = Some(BtwSessionState { + thread_id, + thread: Arc::clone(&thread), + listener_handle, + final_message: None, + }); + + let op = Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: trimmed_prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }; + if let Err(err) = thread.submit(op).await { + self.chat_widget + .add_error_message(format!("Failed to submit `/btw`: {err}")); + self.cleanup_btw_session(); + } + } + + 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_panel(&message); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("`/btw` failed: {err}")); + self.cleanup_btw_session(); + } + } + } + + pub(crate) fn insert_btw_summary(&mut self) { + let Some(message) = self + .btw_session + .as_ref() + .and_then(|session| session.final_message.as_deref()) + else { + self.chat_widget + .add_error_message("`/btw` summary is unavailable.".to_string()); + self.cleanup_btw_session(); + return; + }; + + let summary = summarize_message(message); + self.insert_btw_text(summary, "Inserted `/btw` summary into the composer."); + } + + pub(crate) fn insert_btw_full(&mut self) { + let Some(message) = self + .btw_session + .as_ref() + .and_then(|session| session.final_message.as_deref()) + else { + self.chat_widget + .add_error_message("`/btw` answer is unavailable.".to_string()); + self.cleanup_btw_session(); + return; + }; + + let text = full_insert_text(message); + self.insert_btw_text(text, "Inserted `/btw` answer into the composer."); + } + + pub(crate) fn discard_btw_session(&mut self) { + self.chat_widget.add_info_message( + "Discarded `/btw` discussion.".to_string(), + /*hint*/ None, + ); + self.cleanup_btw_session(); + } + + fn insert_btw_text(&mut self, text: String, confirmation: &str) { + if !self + .chat_widget + .composer_text_with_pending() + .trim() + .is_empty() + { + self.chat_widget.insert_str("\n\n"); + } + self.chat_widget.insert_str(&text); + self.chat_widget + .add_info_message(confirmation.to_string(), /*hint*/ None); + self.cleanup_btw_session(); + } + + fn cleanup_btw_session(&mut self) { + let Some(session) = self.btw_session.take() else { + return; + }; + + session.listener_handle.abort(); + let thread = session.thread; + let thread_id = session.thread_id; + let server = Arc::clone(&self.server); + tokio::spawn(async move { + let _ = thread.shutdown_and_wait().await; + let _ = server.remove_thread(&thread_id).await; + }); + } + + fn open_btw_loading_panel(&mut self) { + self.chat_widget + .show_selection_view(btw_loading_view_params()); + } + + fn open_btw_result_panel(&mut self, message: &str) { + self.chat_widget + .show_selection_view(btw_result_view_params(message)); + } +} + +fn btw_loading_view_params() -> SelectionViewParams { + 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: ColumnRenderable::with(vec![ + Box::new("Temporary `/btw` discussion".to_string()) + as Box, + Box::new( + Paragraph::new( + "Codex is answering in a hidden ephemeral thread. Nothing will be written back \ + to the main thread unless you explicitly choose an insert action." + .to_string(), + ) + .wrap(Wrap { trim: false }), + ) as Box, + ]) + .into(), + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +fn btw_result_view_params(message: &str) -> SelectionViewParams { + let preview = preview_text(message); + 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 thread untouched." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))], + dismiss_on_select: true, + ..Default::default() + }, + ], + side_content: Paragraph::new(preview).wrap(Wrap { trim: false }).into(), + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +async fn build_btw_initial_history(rollout_path: Option<&Path>) -> InitialHistory { + let Some(rollout_path) = rollout_path else { + return InitialHistory::New; + }; + let Ok(history) = RolloutRecorder::get_rollout_history(rollout_path).await else { + return InitialHistory::New; + }; + let items = history.get_rollout_items(); + if items.is_empty() { + return InitialHistory::New; + } + + let session_meta = items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(_) => Some(item.clone()), + _ => None, + }); + let latest_turn_context_index = items + .iter() + .enumerate() + .rev() + .find_map(|(index, item)| matches!(item, RolloutItem::TurnContext(_)).then_some(index)); + let latest_turn_context = latest_turn_context_index.map(|index| items[index].clone()); + + let mut used_tokens = 0usize; + let mut selected_tail = Vec::new(); + for (index, item) in items.iter().enumerate().rev() { + if matches!(item, RolloutItem::SessionMeta(_)) { + continue; + } + if Some(index) == latest_turn_context_index { + continue; + } + + let item_tokens = approx_rollout_item_tokens(item); + if !selected_tail.is_empty() + && used_tokens.saturating_add(item_tokens) > BTW_CONTEXT_BUDGET_TOKENS + { + break; + } + used_tokens = used_tokens.saturating_add(item_tokens); + selected_tail.push(item.clone()); + } + selected_tail.reverse(); + + let mut selected = Vec::new(); + if let Some(session_meta) = session_meta { + selected.push(session_meta); + } + if let Some(turn_context) = latest_turn_context { + selected.push(turn_context); + } + selected.extend(selected_tail); + + if selected.is_empty() { + InitialHistory::New + } else { + InitialHistory::Forked(selected) + } +} + +fn approx_rollout_item_tokens(item: &RolloutItem) -> usize { + serde_json::to_string(item) + .ok() + .map(|text| text.len().saturating_add(3) / 4) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::btw_loading_view_params; + use super::btw_result_view_params; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::list_selection_view::ListSelectionView; + use crate::render::renderable::Renderable; + use codex_btw::summarize_message; + 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; + + 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(), tx); + + assert_snapshot!("btw_loading_popup", render_selection_popup(&view, 92, 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, 92, 28)); + } + + #[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." + ); + } +} diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs new file mode 100644 index 000000000..39054cb30 --- /dev/null +++ b/codex-rs/tui/src/app/clawbot.rs @@ -0,0 +1,1194 @@ +mod sessions; +pub(super) mod turns; + +use anyhow::Result; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotStore; +use codex_clawbot::ClawbotTurnMode; +use codex_clawbot::ConnectionStatus; +use codex_clawbot::FeishuConfig; +use codex_clawbot::ProviderEvent; +use codex_clawbot::ProviderKind; +use codex_clawbot::ProviderMessageRef; +use codex_clawbot::ProviderOutboundAction; +use codex_clawbot::ProviderOutboundReaction; +use codex_clawbot::ProviderOutboundTextMessage; +use codex_clawbot::ProviderReactionReceipt; +use codex_clawbot::ProviderRuntime; +use codex_clawbot::ProviderRuntimeState; +use codex_clawbot::ProviderSession; +use codex_clawbot::ProviderSessionRef; +use codex_clawbot::feishu_failure_reply_text; +use codex_protocol::ThreadId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +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 std::sync::Arc; +use tokio::sync::mpsc; + +use self::sessions::CLAWBOT_SESSIONS_PANEL_VIEW_ID; +use self::sessions::feishu_sessions_menu_description; +use self::turns::FEISHU_AUTO_ACK_DISPLAY; +use self::turns::FEISHU_AUTO_ACK_EMOJI_TYPE; +use self::turns::PendingClawbotTurn; +use super::App; +use crate::app_event::AppEvent; +use crate::app_event::ClawbotFeishuConfigField; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::history_cell; +use crate::history_cell::HistoryCell; + +const CLAWBOT_PANEL_VIEW_ID: &str = "fork-clawbot-panel"; +const CLAWBOT_CONFIG_PANEL_VIEW_ID: &str = "fork-clawbot-config-panel"; +const CLAWBOT_SESSION_ACTIONS_VIEW_ID: &str = "fork-clawbot-session-actions-panel"; + +pub(crate) fn control_panel_clawbot_item() -> SelectionItem { + SelectionItem { + name: "Clawbot".to_string(), + description: None, + selected_description: Some( + "Inspect the workspace-local IM gateway and session bindings.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenClawbotPanel))], + dismiss_on_select: false, + ..Default::default() + } +} + +impl ClawbotFeishuConfigField { + fn title(self) -> &'static str { + match self { + Self::AppId => "App ID", + Self::AppSecret => "App Secret", + Self::VerificationToken => "Verification Token", + Self::EncryptKey => "Encrypt Key", + Self::BotOpenId => "Bot Open ID", + Self::BotUserId => "Bot User ID", + } + } + + pub(crate) fn prompt_title(self) -> String { + format!("Edit Feishu {}", self.title()) + } + + pub(crate) fn prompt_placeholder(self) -> String { + match self { + Self::AppId => "Paste the Feishu app_id and press Enter".to_string(), + Self::AppSecret => "Paste the Feishu app_secret and press Enter".to_string(), + Self::VerificationToken => "Paste the verification token and press Enter".to_string(), + Self::EncryptKey => "Paste the encrypt key and press Enter".to_string(), + Self::BotOpenId => "Paste the bot open_id and press Enter".to_string(), + Self::BotUserId => "Paste the bot user_id and press Enter".to_string(), + } + } + + pub(crate) fn prompt_context_label(self) -> String { + let scope = "Workspace-local clawbot config"; + match self { + Self::AppId | Self::AppSecret => format!("{scope} · Required for API and websocket"), + Self::VerificationToken | Self::EncryptKey => { + format!("{scope} · Optional for webhook verification") + } + Self::BotOpenId | Self::BotUserId => { + format!("{scope} · Optional bot identity metadata") + } + } + } + + fn selected_description(self) -> String { + match self { + Self::AppId | Self::AppSecret => { + "Edit this required Feishu credential and persist it under .codex/clawbot." + .to_string() + } + Self::VerificationToken | Self::EncryptKey => { + "Edit this optional Feishu gateway setting and persist it under .codex/clawbot." + .to_string() + } + Self::BotOpenId | Self::BotUserId => { + "Edit this optional bot identity field and persist it under .codex/clawbot." + .to_string() + } + } + } + + fn current_value(self, config: &FeishuConfig) -> String { + match self { + Self::AppId => config.app_id.clone(), + Self::AppSecret => config.app_secret.clone(), + Self::VerificationToken => config.verification_token.clone().unwrap_or_default(), + Self::EncryptKey => config.encrypt_key.clone().unwrap_or_default(), + Self::BotOpenId => config.bot_open_id.clone().unwrap_or_default(), + Self::BotUserId => config.bot_user_id.clone().unwrap_or_default(), + } + } + + fn value_description(self, config: Option<&FeishuConfig>) -> String { + let value = config + .map(|config| self.current_value(config)) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + match value { + Some(value) if self.is_secret() => format!("Configured · {}", mask_secret(&value)), + Some(value) => format!("Configured · {}", truncate_value(&value, 28)), + None => "Not set".to_string(), + } + } + + fn is_secret(self) -> bool { + matches!( + self, + Self::AppSecret | Self::VerificationToken | Self::EncryptKey + ) + } +} + +impl App { + pub(crate) fn open_clawbot_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(CLAWBOT_PANEL_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + CLAWBOT_PANEL_VIEW_ID, + self.clawbot_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.clawbot_panel_params(initial_selected_idx)); + } + } + + pub(crate) fn open_clawbot_session_actions(&mut self, session: ProviderSessionRef) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(CLAWBOT_SESSION_ACTIONS_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + CLAWBOT_SESSION_ACTIONS_VIEW_ID, + self.clawbot_session_actions_panel_params(session.clone(), initial_selected_idx), + ) { + self.chat_widget.show_selection_view( + self.clawbot_session_actions_panel_params(session, initial_selected_idx), + ); + } + } + + pub(crate) fn open_clawbot_config_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(CLAWBOT_CONFIG_PANEL_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + CLAWBOT_CONFIG_PANEL_VIEW_ID, + self.clawbot_config_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.clawbot_config_panel_params(initial_selected_idx)); + } + } + + pub(crate) fn open_clawbot_feishu_config_prompt( + &mut self, + field: ClawbotFeishuConfigField, + ) -> Result<()> { + let config = self + .clawbot_runtime()? + .snapshot() + .config + .feishu + .clone() + .unwrap_or_default(); + self.chat_widget + .open_clawbot_feishu_config_prompt(field, field.current_value(&config)); + Ok(()) + } + + pub(crate) fn open_clawbot_manual_bind_prompt(&mut self) { + let current_thread_label = self + .active_thread_id + .map(|thread_id| thread_id.to_string()) + .unwrap_or_else(|| "No active thread".to_string()); + let current_value = self + .active_thread_id + .and_then(|thread_id| { + self.clawbot_runtime() + .ok() + .and_then(|runtime| { + runtime + .bound_session_for_thread(&thread_id.to_string()) + .ok() + }) + .flatten() + .map(|session| session.session_id) + }) + .unwrap_or_default(); + self.chat_widget + .open_clawbot_manual_bind_prompt(current_value, current_thread_label); + } + + pub(crate) async fn clawbot_connect_current_thread( + &mut self, + session: ProviderSessionRef, + ) -> Result<()> { + let Some(thread_id) = self.active_thread_id else { + self.open_clawbot_sessions_panel(); + return Ok(()); + }; + + let mut runtime = self.clawbot_runtime()?; + runtime.connect_session_to_thread(&session, thread_id.to_string())?; + self.drain_clawbot_cached_messages_to_thread(session) + .await?; + self.open_clawbot_sessions_panel(); + Ok(()) + } + + pub(crate) fn clawbot_disconnect(&mut self, session: ProviderSessionRef) -> Result<()> { + let mut runtime = self.clawbot_runtime()?; + runtime.disconnect_session(&session)?; + self.open_clawbot_sessions_panel(); + Ok(()) + } + + pub(crate) async fn save_clawbot_manual_bind_session_id( + &mut self, + session_id: String, + ) -> Result<()> { + let Some(thread_id) = self.active_thread_id else { + self.open_clawbot_sessions_panel(); + return Ok(()); + }; + + let trimmed = session_id.trim().to_string(); + if trimmed.is_empty() { + self.open_clawbot_sessions_panel(); + return Ok(()); + } + + let mut runtime = self.clawbot_runtime()?; + let session = ProviderSessionRef::new(ProviderKind::Feishu, trimmed); + runtime.connect_session_to_thread(&session, thread_id.to_string())?; + self.drain_clawbot_cached_messages_to_thread(session) + .await?; + self.open_clawbot_sessions_panel(); + Ok(()) + } + + pub(crate) fn save_clawbot_feishu_config_value( + &mut self, + field: ClawbotFeishuConfigField, + value: String, + ) -> Result<()> { + let mut runtime = self.clawbot_runtime()?; + 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 = Some(trimmed); + } + ClawbotFeishuConfigField::EncryptKey => { + config.encrypt_key = Some(trimmed); + } + ClawbotFeishuConfigField::BotOpenId => { + config.bot_open_id = Some(trimmed); + } + ClawbotFeishuConfigField::BotUserId => { + config.bot_user_id = Some(trimmed); + } + } + + let next_config = (!config.is_empty()).then_some(config.clone()); + runtime.update_feishu_config(next_config)?; + runtime.persist_runtime_state(feishu_runtime_state_for_config(config))?; + self.open_clawbot_config_panel(); + Ok(()) + } + + pub(crate) fn save_clawbot_turn_mode(&mut self, mode: ClawbotTurnMode) -> Result<()> { + let mut runtime = self.clawbot_runtime()?; + runtime.update_turn_mode(mode)?; + self.open_clawbot_panel(); + Ok(()) + } + + pub(crate) async fn clawbot_flush_cached_messages( + &mut self, + session: ProviderSessionRef, + ) -> Result<()> { + self.drain_clawbot_cached_messages_to_thread(session) + .await?; + self.open_clawbot_sessions_panel(); + Ok(()) + } + + pub(crate) async fn bootstrap_clawbot_runtime(&mut self) -> Result<()> { + self.start_clawbot_provider_runtime(ProviderKind::Feishu)?; + + let store = ClawbotStore::new(self.config.cwd.clone()); + let snapshot = store.load_snapshot()?; + let sessions_to_flush = snapshot + .sessions + .iter() + .filter(|session| session.provider == ProviderKind::Feishu) + .filter(|session| session.bound_thread_id.is_some()) + .filter(|session| session.unread_count > 0) + .map(ProviderSession::session_ref) + .collect::>(); + for session in sessions_to_flush { + self.drain_clawbot_cached_messages_to_thread(session) + .await?; + } + Ok(()) + } + + async fn drain_clawbot_cached_messages_to_thread( + &mut self, + session: ProviderSessionRef, + ) -> Result<()> { + let store = ClawbotStore::new(self.config.cwd.clone()); + let snapshot = store.load_snapshot()?; + let Some(binding) = snapshot + .bindings + .iter() + .find(|binding| binding.session_ref() == session) + else { + let mut runtime = self.clawbot_runtime()?; + let _flushed = runtime.flush_cached_messages(&session)?; + self.refresh_clawbot_views_if_active(); + return Ok(()); + }; + + let thread_id = ThreadId::from_string(&binding.thread_id).map_err(|error| { + anyhow::anyhow!("invalid bound thread id `{}`: {error}", binding.thread_id) + })?; + let cached_messages = store + .load_unread_messages()? + .into_iter() + .filter(|message| message.session_ref() == session) + .collect::>(); + let session_label = snapshot + .sessions + .iter() + .find(|candidate| candidate.session_ref() == session) + .and_then(|candidate| candidate.display_name.clone()) + .unwrap_or_else(|| session.session_id.clone()); + let turn_mode = snapshot.config.turn_mode; + + for cached_message in &cached_messages { + let source_message = ProviderMessageRef::new( + cached_message.provider, + cached_message.session_id.clone(), + cached_message.message_id.clone(), + ); + if let Err(err) = self.send_clawbot_auto_ack(source_message, thread_id).await { + self.chat_widget.add_error_message(format!( + "Failed to add clawbot auto reaction for thread {thread_id}: {err}" + )); + } + self.insert_clawbot_origin_info_cell(thread_id, &session_label); + let turn_id = self + .submit_clawbot_message_to_thread(thread_id, cached_message.text.clone(), turn_mode) + .await?; + self.register_pending_clawbot_turn(PendingClawbotTurn { + turn_id, + thread_id, + turn_mode, + }); + } + + let mut runtime = self.clawbot_runtime()?; + let _flushed = runtime.flush_cached_messages(&session)?; + self.refresh_clawbot_views_if_active(); + Ok(()) + } + + pub(crate) async fn clawbot_retry_connection(&mut self, provider: ProviderKind) -> Result<()> { + self.start_clawbot_provider_runtime(provider)?; + self.open_clawbot_sessions_panel(); + Ok(()) + } + + pub(crate) async fn handle_clawbot_provider_event( + &mut self, + event: ProviderEvent, + ) -> Result<()> { + let inbound_session = match &event { + ProviderEvent::InboundMessage(message) => Some(message.session.clone()), + _ => None, + }; + + let mut runtime = self.clawbot_runtime()?; + runtime.apply_provider_event(event)?; + let session_to_flush = inbound_session.filter(|session| { + runtime + .snapshot() + .sessions + .iter() + .find(|candidate| candidate.session_ref() == *session) + .and_then(|candidate| candidate.bound_thread_id.as_ref()) + .is_some() + }); + + self.refresh_clawbot_views_if_active(); + if let Some(session) = session_to_flush { + self.drain_clawbot_cached_messages_to_thread(session) + .await?; + } + Ok(()) + } + + pub(crate) async fn maybe_auto_respond_to_clawbot_interactive_event( + &mut self, + thread_id: ThreadId, + event: &EventMsg, + ) -> Result { + match event { + EventMsg::RequestUserInput(request) => { + self.auto_respond_to_clawbot_user_input_request(thread_id, &request.turn_id) + .await + } + EventMsg::RequestPermissions(request) => { + self.auto_respond_to_clawbot_permissions_request( + thread_id, + &request.turn_id, + &request.call_id, + ) + .await + } + _ => Ok(false), + } + } + + pub(crate) async fn handle_clawbot_thread_terminal_event( + &mut self, + thread_id: ThreadId, + event: &EventMsg, + ) { + let _pending_turn = self.take_pending_clawbot_turn(thread_id, event); + + let outbound_text = match event { + EventMsg::TurnComplete(turn_complete) => turn_complete + .last_agent_message + .as_deref() + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(ToOwned::to_owned), + EventMsg::Error(error) => Some(feishu_failure_reply_text(&error.message)), + _ => None, + }; + + let Some(text) = outbound_text else { + return; + }; + + if let Err(err) = self.send_clawbot_thread_reply(thread_id, text).await { + self.chat_widget.add_error_message(format!( + "Failed to forward clawbot reply for thread {thread_id}: {err}" + )); + } + } + + fn clawbot_panel_params(&self, initial_selected_idx: Option) -> SelectionViewParams { + let store = ClawbotStore::new(self.config.cwd.clone()); + let snapshot = store.load_snapshot().unwrap_or_default(); + let provider_state = snapshot.provider_state(ProviderKind::Feishu); + let config_description = feishu_config_summary(snapshot.config.feishu.as_ref()); + let turn_mode = snapshot.config.turn_mode; + let next_turn_mode = match turn_mode { + ClawbotTurnMode::Interactive => ClawbotTurnMode::NonInteractive, + ClawbotTurnMode::NonInteractive => ClawbotTurnMode::Interactive, + }; + let 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 question, permission, and approval prompts do not block remote sessions.".to_string() + } + ClawbotTurnMode::NonInteractive => { + "Restore normal interactive prompts 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: "Sessions".to_string(), + description: Some(feishu_sessions_menu_description( + provider_state, + &snapshot.sessions, + )), + selected_description: Some( + "Inspect Feishu session status and run scan / clear operations." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenClawbotSessionsPanel))], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Configuration".to_string(), + description: Some(config_description), + selected_description: Some( + "Edit and persist workspace-local Feishu credentials.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenClawbotConfigPanel))], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Bindings".to_string(), + description: Some(format!( + "{} session-thread bindings persisted.", + snapshot.bindings.len() + )), + selected_description: Some( + "Each binding maps one external IM session to one Codex thread." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Unread Cache".to_string(), + description: Some(format!( + "{} cached inbound messages awaiting binding or replay.", + snapshot.unread_message_count + )), + selected_description: Some( + "Unbound sessions accumulate unread messages here until the operator connects them." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }, + ]; + let discovered_session_count = snapshot.sessions.len(); + + SelectionViewParams { + view_id: Some(CLAWBOT_PANEL_VIEW_ID), + title: Some("Clawbot".to_string()), + subtitle: Some(format!( + "Feishu private chat bridge · {discovered_session_count} sessions discovered." + )), + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(store.root_dir().display().to_string()), + initial_selected_idx, + items, + ..Default::default() + } + } + + fn clawbot_config_panel_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let store = ClawbotStore::new(self.config.cwd.clone()); + let snapshot = store.load_snapshot().unwrap_or_default(); + let config = snapshot.config.feishu.unwrap_or_default(); + let items = [ + ClawbotFeishuConfigField::AppId, + ClawbotFeishuConfigField::AppSecret, + ClawbotFeishuConfigField::VerificationToken, + ClawbotFeishuConfigField::EncryptKey, + ClawbotFeishuConfigField::BotOpenId, + ClawbotFeishuConfigField::BotUserId, + ] + .into_iter() + .map(|field| SelectionItem { + name: field.title().to_string(), + description: Some(field.value_description(Some(&config))), + selected_description: Some(field.selected_description()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenClawbotFeishuConfigPrompt { field }) + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + + SelectionViewParams { + view_id: Some(CLAWBOT_CONFIG_PANEL_VIEW_ID), + title: Some("Clawbot".to_string()), + subtitle: Some("Feishu Configuration".to_string()), + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(store.config_path().display().to_string()), + initial_selected_idx, + items, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenClawbotPanel))), + ..Default::default() + } + } + + fn clawbot_session_actions_panel_params( + &self, + session: ProviderSessionRef, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let store = ClawbotStore::new(self.config.cwd.clone()); + let snapshot = store.load_snapshot().unwrap_or_default(); + let selected_session = snapshot + .sessions + .iter() + .find(|candidate| candidate.session_ref() == session) + .cloned(); + let title = selected_session + .as_ref() + .and_then(|selected_session| selected_session.display_name.clone()) + .unwrap_or_else(|| session.session_id.clone()); + + let items = match selected_session { + Some(selected_session) => self.clawbot_session_action_items(selected_session), + None => vec![SelectionItem { + name: "Session not found".to_string(), + description: Some( + "The persisted session disappeared before the actions panel opened." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }], + }; + + SelectionViewParams { + view_id: Some(CLAWBOT_SESSION_ACTIONS_VIEW_ID), + title: Some("Clawbot".to_string()), + subtitle: Some(format!("Session Actions · {title}")), + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(store.root_dir().display().to_string()), + initial_selected_idx, + items, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenClawbotSessionsPanel))), + ..Default::default() + } + } + + fn clawbot_session_action_items(&self, session: ProviderSession) -> Vec { + let session_ref = session.session_ref(); + if session.bound_thread_id.is_none() { + let session_for_action = session_ref.clone(); + return vec![SelectionItem { + name: "Connect To Current Thread".to_string(), + description: Some(format!( + "Bind this session to the current thread and persist the mapping. Current unread: {}.", + session.unread_count + )), + selected_description: Some( + "Future inbound messages for this session will route to the current thread." + .to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotConnectCurrentThread { + session: session_for_action.clone(), + }) + })], + dismiss_on_select: false, + is_disabled: self.active_thread_id.is_none(), + ..Default::default() + }]; + } + + let session_for_disconnect = session_ref.clone(); + let session_for_flush = session_ref; + vec![ + SelectionItem { + name: "Disconnect".to_string(), + description: Some( + "Remove the persisted thread binding and stop routing this session." + .to_string(), + ), + selected_description: Some( + "Unread cache is preserved; only the session-thread binding is removed." + .to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotDisconnect { + session: session_for_disconnect.clone(), + }) + })], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Flush Cached Messages".to_string(), + description: Some(format!( + "Clear {} cached inbound messages for this session.", + session.unread_count + )), + selected_description: Some( + "This drains the persisted unread cache for the selected session.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotFlushCachedMessages { + session: session_for_flush.clone(), + }) + })], + dismiss_on_select: false, + is_disabled: session.unread_count == 0, + ..Default::default() + }, + ] + } + + fn clawbot_runtime(&self) -> Result { + ClawbotRuntime::load(self.config.cwd.clone().into()) + } + + fn refresh_clawbot_views_if_active(&mut self) { + let clawbot_selected_idx = self + .chat_widget + .selected_index_for_active_view(CLAWBOT_PANEL_VIEW_ID); + if clawbot_selected_idx.is_some() { + let _ = self.chat_widget.replace_selection_view_if_active( + CLAWBOT_PANEL_VIEW_ID, + self.clawbot_panel_params(clawbot_selected_idx), + ); + } + let clawbot_sessions_selected_idx = self + .chat_widget + .selected_index_for_active_view(CLAWBOT_SESSIONS_PANEL_VIEW_ID); + if clawbot_sessions_selected_idx.is_some() { + let _ = self.chat_widget.replace_selection_view_if_active( + CLAWBOT_SESSIONS_PANEL_VIEW_ID, + self.clawbot_sessions_panel_params(clawbot_sessions_selected_idx), + ); + } + } + + pub(crate) fn replay_clawbot_history_cells_for_active_thread(&mut self) { + let Some(thread_id) = self.active_thread_id else { + return; + }; + let Some(cells) = self.clawbot_thread_history_cells.get(&thread_id) else { + return; + }; + let width = 80; + for cell in cells { + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(width); + if !display.is_empty() { + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, ratatui::text::Line::default()); + } else { + self.has_emitted_history_lines = true; + } + } + self.deferred_history_lines.extend(display); + } + } + } + + fn insert_clawbot_origin_info_cell(&mut self, thread_id: ThreadId, session_label: &str) { + let stored_cell: Arc = Arc::new(clawbot_origin_info_cell(session_label)); + self.clawbot_thread_history_cells + .entry(thread_id) + .or_default() + .push(stored_cell); + if self.active_thread_id == Some(thread_id) { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + clawbot_origin_info_cell(session_label), + ))); + } + } + + fn insert_clawbot_action_info_cell(&mut self, thread_id: ThreadId, title: &str, hint: &str) { + let stored_cell: Arc = Arc::new(clawbot_action_info_cell(title, hint)); + self.clawbot_thread_history_cells + .entry(thread_id) + .or_default() + .push(stored_cell); + if self.active_thread_id == Some(thread_id) { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + clawbot_action_info_cell(title, hint), + ))); + } + } + + async fn submit_clawbot_message_to_thread( + &mut self, + 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 op = { + let thread = self.server.get_thread(thread_id).await.map_err(|error| { + anyhow::anyhow!("failed to find bound thread {thread_id}: {error}") + })?; + let config_snapshot = thread.config_snapshot().await; + Op::UserTurn { + items: vec![codex_protocol::user_input::UserInput::Text { + text: trimmed, + text_elements: Vec::new(), + }], + cwd: config_snapshot.cwd, + approval_policy: clawbot_approval_policy( + config_snapshot.approval_policy, + turn_mode, + ), + approvals_reviewer: Some(config_snapshot.approvals_reviewer), + sandbox_policy: config_snapshot.sandbox_policy, + model: config_snapshot.model, + effort: config_snapshot.reasoning_effort, + summary: None, + service_tier: config_snapshot.service_tier.map(Some), + final_output_json_schema: None, + collaboration_mode: None, + personality: self.config.personality, + } + }; + + let replay_state_op = + super::ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone()); + crate::session_log::log_outbound_op(&op); + let thread = + self.server.get_thread(thread_id).await.map_err(|error| { + anyhow::anyhow!("failed to find bound thread {thread_id}: {error}") + })?; + let turn_id = thread + .submit(op) + .await + .map_err(|error| anyhow::anyhow!("failed to submit clawbot op: {error}"))?; + + if let Some(op) = replay_state_op.as_ref() { + self.note_thread_outbound_op(thread_id, op).await; + } + Ok(turn_id) + } + + async fn send_clawbot_thread_reply(&mut self, thread_id: ThreadId, text: String) -> Result<()> { + let runtime = self.clawbot_runtime()?; + let Some(session) = runtime.bound_session_for_thread(&thread_id.to_string())? else { + return Ok(()); + }; + + self.send_clawbot_outbound_text(ProviderOutboundTextMessage { session, text }) + .await + } + + fn register_pending_clawbot_turn(&mut self, turn: PendingClawbotTurn) { + self.clawbot_pending_turns + .entry(turn.thread_id) + .or_default() + .push_back(turn); + } + + fn take_pending_clawbot_turn( + &mut self, + thread_id: ThreadId, + event: &EventMsg, + ) -> Option { + let queue = self.clawbot_pending_turns.get_mut(&thread_id)?; + let pending = match event { + EventMsg::TurnComplete(turn_complete) => queue + .iter() + .position(|pending| pending.turn_id == turn_complete.turn_id) + .and_then(|index| queue.remove(index)), + EventMsg::Error(_) => queue.pop_front(), + _ => None, + }; + if queue.is_empty() { + self.clawbot_pending_turns.remove(&thread_id); + } + pending + } + + 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) + } + + async fn send_clawbot_auto_ack( + &mut self, + target: ProviderMessageRef, + thread_id: ThreadId, + ) -> Result<()> { + self.send_clawbot_outbound_reaction(ProviderOutboundReaction { + target, + emoji_type: FEISHU_AUTO_ACK_EMOJI_TYPE.to_string(), + }) + .await?; + self.insert_clawbot_action_info_cell( + thread_id, + "Feishu auto reaction", + FEISHU_AUTO_ACK_DISPLAY, + ); + Ok(()) + } + + async fn auto_respond_to_clawbot_user_input_request( + &mut self, + thread_id: ThreadId, + turn_id: &str, + ) -> Result { + let Some(turn_mode) = self.clawbot_turn_mode_for_turn(thread_id, turn_id) else { + return Ok(false); + }; + if !turn_mode.uses_noninteractive_prompt_handling() { + return Ok(false); + } + + self.submit_op_to_thread( + thread_id, + Op::UserInputAnswer { + id: turn_id.to_string(), + response: RequestUserInputResponse { + answers: std::collections::HashMap::new(), + }, + }, + ) + .await; + self.insert_clawbot_action_info_cell( + thread_id, + "Clawbot auto response", + "question tool skipped", + ); + Ok(true) + } + + async fn auto_respond_to_clawbot_permissions_request( + &mut self, + thread_id: ThreadId, + turn_id: &str, + call_id: &str, + ) -> Result { + let Some(turn_mode) = self.clawbot_turn_mode_for_turn(thread_id, turn_id) else { + return Ok(false); + }; + if !turn_mode.uses_noninteractive_prompt_handling() { + return Ok(false); + } + + self.submit_op_to_thread( + thread_id, + Op::RequestPermissionsResponse { + id: call_id.to_string(), + response: RequestPermissionsResponse { + permissions: Default::default(), + scope: PermissionGrantScope::Turn, + }, + }, + ) + .await; + self.insert_clawbot_action_info_cell( + thread_id, + "Clawbot auto response", + "permission request denied", + ); + Ok(true) + } + + fn start_clawbot_provider_runtime(&mut self, provider: ProviderKind) -> Result<()> { + let runtime = self.clawbot_runtime()?; + match provider { + ProviderKind::Feishu => { + if let Some(task) = self.clawbot_provider_tasks.remove(&provider) { + task.abort(); + } + + if let Some(provider_runtime) = runtime.feishu_provider() { + let app_event_tx = self.app_event_tx.clone(); + let handle = tokio::spawn(async move { + let (provider_event_tx, mut provider_event_rx) = + mpsc::unbounded_channel::(); + let app_event_forwarder = tokio::spawn(async move { + while let Some(event) = provider_event_rx.recv().await { + app_event_tx.send(AppEvent::ClawbotProviderEvent { + event: Box::new(event), + }); + } + }); + + let _ = provider_runtime.run(provider_event_tx).await; + app_event_forwarder.abort(); + }); + self.clawbot_provider_tasks.insert(provider, handle); + } + } + } + Ok(()) + } + + async fn send_clawbot_outbound_text( + &mut self, + message: ProviderOutboundTextMessage, + ) -> Result<()> { + if let Some(tx) = &self.clawbot_outbound_tx { + tx.send(ProviderOutboundAction::Text(message)) + .map_err(|err| { + anyhow::anyhow!("failed to capture clawbot outbound message: {err}") + })?; + return Ok(()); + } + + match message.session.provider { + ProviderKind::Feishu => { + let runtime = self.clawbot_runtime()?; + let Some(mut provider_runtime) = runtime.feishu_provider() else { + return Err(anyhow::anyhow!("missing Feishu provider config")); + }; + provider_runtime.send_text(message).await?; + } + } + + self.refresh_clawbot_views_if_active(); + Ok(()) + } + + async fn send_clawbot_outbound_reaction( + &mut self, + reaction: ProviderOutboundReaction, + ) -> Result { + if let Some(tx) = &self.clawbot_outbound_tx { + let receipt = ProviderReactionReceipt { + target: reaction.target.clone(), + reaction_id: format!("captured-{}", reaction.emoji_type), + emoji_type: reaction.emoji_type.clone(), + }; + tx.send(ProviderOutboundAction::AddReaction(reaction)) + .map_err(|err| anyhow::anyhow!("failed to capture clawbot reaction: {err}"))?; + return Ok(receipt); + } + + let receipt = match reaction.target.provider { + ProviderKind::Feishu => { + let runtime = self.clawbot_runtime()?; + let Some(mut provider_runtime) = runtime.feishu_provider() else { + return Err(anyhow::anyhow!("missing Feishu provider config")); + }; + provider_runtime.add_reaction(reaction).await? + } + }; + + self.refresh_clawbot_views_if_active(); + Ok(receipt) + } +} + +fn feishu_config_summary(config: Option<&FeishuConfig>) -> String { + let Some(config) = config else { + return "No Feishu credentials saved yet.".to_string(); + }; + + if config.has_api_credentials() { + let verification_state = config + .verification_token + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + .then_some("verification token set") + .unwrap_or("verification token not set"); + format!("API credentials configured · {verification_state}.") + } else if config.is_empty() { + "No Feishu credentials saved yet.".to_string() + } else { + "Incomplete API credentials. Set both app_id and app_secret.".to_string() + } +} + +fn feishu_runtime_state_for_config(config: FeishuConfig) -> ProviderRuntimeState { + if config.has_api_credentials() { + ProviderRuntimeState { + provider: ProviderKind::Feishu, + connection: ConnectionStatus::Disconnected, + last_error: None, + updated_at: None, + } + } else { + ProviderRuntimeState::unconfigured(ProviderKind::Feishu) + } +} + +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 approval prompts." + .to_string() + } + } +} + +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 mask_secret(value: &str) -> String { + let char_count = value.chars().count(); + if char_count <= 4 { + return "*".repeat(char_count.max(1)); + } + + let suffix: String = value + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect(); + format!("{}{}", "*".repeat(char_count - 4), suffix) +} + +fn truncate_value(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 + } +} + +fn clawbot_origin_info_cell(session_label: &str) -> history_cell::PlainHistoryCell { + history_cell::new_info_event( + "Feishu message".to_string(), + Some(session_label.to_string()), + ) +} + +fn clawbot_action_info_cell(title: &str, hint: &str) -> history_cell::PlainHistoryCell { + history_cell::new_info_event(title.to_string(), Some(hint.to_string())) +} diff --git a/codex-rs/tui/src/app/clawbot/sessions.rs b/codex-rs/tui/src/app/clawbot/sessions.rs new file mode 100644 index 000000000..02b42f116 --- /dev/null +++ b/codex-rs/tui/src/app/clawbot/sessions.rs @@ -0,0 +1,260 @@ +use std::collections::HashSet; + +use anyhow::Result; +use codex_clawbot::ClawbotStore; +use codex_clawbot::ConnectionStatus; +use codex_clawbot::ProviderKind; +use codex_clawbot::ProviderRuntimeState; +use codex_clawbot::ProviderSession; + +use super::App; +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +pub(super) const CLAWBOT_SESSIONS_PANEL_VIEW_ID: &str = "fork-clawbot-sessions-panel"; + +pub(super) fn feishu_sessions_menu_description( + provider_state: Option<&ProviderRuntimeState>, + sessions: &[ProviderSession], +) -> String { + let total_sessions = sessions.len(); + let bound_sessions = sessions + .iter() + .filter(|session| session.bound_thread_id.is_some()) + .count(); + match provider_state { + Some(state) => format!( + "{} · {} total · {} bound", + state.connection.label(), + total_sessions, + bound_sessions + ), + None => format!("unconfigured · {total_sessions} total · {bound_sessions} bound"), + } +} + +impl App { + pub(crate) fn open_clawbot_sessions_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(CLAWBOT_SESSIONS_PANEL_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + CLAWBOT_SESSIONS_PANEL_VIEW_ID, + self.clawbot_sessions_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.clawbot_sessions_panel_params(initial_selected_idx)); + } + } + + pub(crate) async fn clawbot_scan_sessions(&mut self, provider: ProviderKind) -> Result<()> { + let mut runtime = self.clawbot_runtime()?; + runtime.scan_provider_sessions(provider).await?; + self.open_clawbot_sessions_panel(); + Ok(()) + } + + pub(crate) fn clawbot_clear_sessions(&mut self, provider: ProviderKind) -> Result<()> { + let mut runtime = self.clawbot_runtime()?; + runtime.clear_unbound_sessions(provider)?; + self.open_clawbot_sessions_panel(); + Ok(()) + } + + pub(super) fn clawbot_sessions_panel_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let store = ClawbotStore::new(self.config.cwd.clone()); + let snapshot = store.load_snapshot().unwrap_or_default(); + let provider_state = snapshot.provider_state(ProviderKind::Feishu); + let bound_session_refs = snapshot + .sessions + .iter() + .filter(|session| session.provider == ProviderKind::Feishu) + .filter(|session| session.bound_thread_id.is_some()) + .map(ProviderSession::session_ref) + .collect::>(); + let clearable_session_count = snapshot + .sessions + .iter() + .filter(|session| session.provider == ProviderKind::Feishu) + .filter(|session| session.bound_thread_id.is_none()) + .count(); + let clearable_unread_count = store + .load_unread_messages() + .unwrap_or_default() + .into_iter() + .filter(|message| message.provider == ProviderKind::Feishu) + .filter(|message| !bound_session_refs.contains(&message.session_ref())) + .count(); + let status_description = + feishu_sessions_menu_description(provider_state, &snapshot.sessions); + let status_selected_description = provider_state + .and_then(|state| state.last_error.as_ref()) + .map(|error| format!("Last session/runtime error: {error}")) + .unwrap_or_else(|| { + "Inspect Feishu session status and manage scan / clear operations.".to_string() + }); + let retry_description = match provider_state.map(|state| state.connection) { + Some(ConnectionStatus::Connected) => { + "Reconnect the Feishu gateway and refresh websocket delivery." + } + Some(ConnectionStatus::Connecting) => { + "Reconnect the Feishu gateway if the current startup looks stuck." + } + Some(ConnectionStatus::Disconnected | ConnectionStatus::Error) => { + "Reconnect the Feishu gateway using the persisted workspace credentials." + } + Some(ConnectionStatus::Unconfigured) | None => { + "Persist Feishu app credentials first, then retry the gateway connection." + } + }; + + let mut items = vec![ + SelectionItem { + name: "Status".to_string(), + description: Some(status_description), + selected_description: Some(status_selected_description), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Retry Connection".to_string(), + description: Some(retry_description.to_string()), + selected_description: Some( + "Restart the Feishu runtime task and persist the refreshed connection state." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::ClawbotRetryConnection { + provider: ProviderKind::Feishu, + }) + })], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Manual Bind Session ID".to_string(), + description: Some(match self.active_thread_id { + Some(thread_id) => { + format!("Bind a Feishu chat_id directly to thread {thread_id}.") + } + None => "Open a thread first, then manually bind a Feishu chat_id." + .to_string(), + }), + selected_description: Some( + "Use this when a Feishu p2p session is not visible in the discovered session list." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenClawbotManualBindPrompt))], + dismiss_on_select: true, + is_disabled: self.active_thread_id.is_none(), + ..Default::default() + }, + SelectionItem { + name: "Scan Sessions".to_string(), + description: Some( + "Refresh the discovered Feishu session list from the provider API." + .to_string(), + ), + selected_description: Some( + "Use the current Feishu credentials to rescan sessions and refresh status." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::ClawbotScanSessions { + provider: ProviderKind::Feishu, + }) + })], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Clear Sessions".to_string(), + description: Some(format!( + "Remove {clearable_session_count} unbound sessions and {clearable_unread_count} cached unread messages." + )), + selected_description: Some( + "Bound sessions and persisted bindings are preserved.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::ClawbotClearSessions { + provider: ProviderKind::Feishu, + }) + })], + dismiss_on_select: false, + is_disabled: clearable_session_count == 0 && clearable_unread_count == 0, + ..Default::default() + }, + ]; + + let feishu_sessions = snapshot + .sessions + .into_iter() + .filter(|session| session.provider == ProviderKind::Feishu) + .collect::>(); + if feishu_sessions.is_empty() { + items.push(SelectionItem { + name: "No Feishu sessions discovered".to_string(), + description: Some( + "Once the gateway is configured and connected, private chats will appear here." + .to_string(), + ), + selected_description: Some( + "Future actions here will connect a discovered session to the current thread." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + items.extend(feishu_sessions.into_iter().map(|session| { + let session_ref = session.session_ref(); + let binding_description = match &session.bound_thread_id { + Some(thread_id) => format!("thread {thread_id}"), + None => "unbound".to_string(), + }; + let selected_description = if session.bound_thread_id.is_some() { + "Manage binding and unread cache for this session.".to_string() + } else { + "Connect this discovered session to the current thread.".to_string() + }; + SelectionItem { + name: session + .display_name + .clone() + .unwrap_or_else(|| session.session_id.clone()), + description: Some(format!( + "{} · {} unread · {}", + session.status.label(), + session.unread_count, + binding_description + )), + selected_description: Some(selected_description), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenClawbotSessionActions { + session: session_ref.clone(), + }) + })], + dismiss_on_select: false, + ..Default::default() + } + })); + } + + SelectionViewParams { + view_id: Some(CLAWBOT_SESSIONS_PANEL_VIEW_ID), + title: Some("Clawbot".to_string()), + subtitle: Some("Sessions".to_string()), + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(store.root_dir().display().to_string()), + initial_selected_idx, + items, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenClawbotPanel))), + ..Default::default() + } + } +} diff --git a/codex-rs/tui/src/app/clawbot/turns.rs b/codex-rs/tui/src/app/clawbot/turns.rs new file mode 100644 index 000000000..c6aba0bc4 --- /dev/null +++ b/codex-rs/tui/src/app/clawbot/turns.rs @@ -0,0 +1,12 @@ +use codex_clawbot::ClawbotTurnMode; +use codex_protocol::ThreadId; + +pub(super) const FEISHU_AUTO_ACK_EMOJI_TYPE: &str = "TONGUE"; +pub(super) const FEISHU_AUTO_ACK_DISPLAY: &str = "😛"; + +#[derive(Debug, Clone)] +pub(crate) struct PendingClawbotTurn { + pub(crate) turn_id: String, + pub(crate) thread_id: ThreadId, + pub(crate) turn_mode: ClawbotTurnMode, +} diff --git a/codex-rs/tui/src/app/display_preferences_menu.rs b/codex-rs/tui/src/app/display_preferences_menu.rs new file mode 100644 index 000000000..a818d354d --- /dev/null +++ b/codex-rs/tui/src/app/display_preferences_menu.rs @@ -0,0 +1,102 @@ +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::display_preferences::DisplayPreferenceKey; +use crate::display_preferences::DisplayPreferences; + +pub(crate) fn control_panel_show_hide_item() -> SelectionItem { + SelectionItem { + name: "Show / Hide UI".to_string(), + description: None, + selected_description: Some( + "Toggle local TUI-only transcript and UI visibility settings.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenDisplayPreferencesPanel) + })], + dismiss_on_select: false, + ..Default::default() + } +} + +pub(crate) fn display_preferences_items( + display_preferences: &DisplayPreferences, +) -> Vec { + [ + DisplayPreferenceKey::RawThinking, + DisplayPreferenceKey::StartupTooltips, + DisplayPreferenceKey::ToolResults, + DisplayPreferenceKey::ExecCommands, + DisplayPreferenceKey::WaitedMessages, + DisplayPreferenceKey::PatchDiffs, + ] + .into_iter() + .map(|key| display_preference_item(display_preferences, key)) + .collect() +} + +fn display_preference_item( + display_preferences: &DisplayPreferences, + key: DisplayPreferenceKey, +) -> SelectionItem { + let enabled = display_preferences.is_enabled(key); + let (name, description) = match (key, enabled) { + (DisplayPreferenceKey::RawThinking, true) => ( + "Hide Raw Thinking", + "Currently visible. Hide raw reasoning text while keeping summaries.", + ), + (DisplayPreferenceKey::RawThinking, false) => ( + "Show Raw Thinking", + "Currently hidden. Reveal raw reasoning text in this TUI only.", + ), + (DisplayPreferenceKey::StartupTooltips, true) => ( + "Hide Startup Tooltips", + "Currently visible. Hide welcome and session tooltip hints.", + ), + (DisplayPreferenceKey::StartupTooltips, false) => ( + "Show Startup Tooltips", + "Currently hidden. Reveal welcome and session tooltip hints.", + ), + (DisplayPreferenceKey::ToolResults, true) => ( + "Hide Tool Results", + "Currently visible. Keep tool invocations but collapse result details.", + ), + (DisplayPreferenceKey::ToolResults, false) => ( + "Show Tool Results", + "Currently hidden. Reveal tool result details in transcript cells.", + ), + (DisplayPreferenceKey::ExecCommands, true) => ( + "Hide Command Execution", + "Currently visible. Hide command execution cells and keep model replies.", + ), + (DisplayPreferenceKey::ExecCommands, false) => ( + "Show Command Execution", + "Currently hidden. Reveal command execution cells in this TUI only.", + ), + (DisplayPreferenceKey::WaitedMessages, true) => ( + "Hide Waited Messages", + "Currently visible. Hide 'Waited for ...' background terminal messages.", + ), + (DisplayPreferenceKey::WaitedMessages, false) => ( + "Show Waited Messages", + "Currently hidden. Reveal 'Waited for ...' background terminal messages.", + ), + (DisplayPreferenceKey::PatchDiffs, true) => ( + "Hide Patch / Edit Diff", + "Currently visible. Collapse patch and edit diff summaries.", + ), + (DisplayPreferenceKey::PatchDiffs, false) => ( + "Show Patch / Edit Diff", + "Currently hidden. Reveal patch and edit diff summaries.", + ), + }; + + SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ToggleDisplayPreference(key)); + })], + dismiss_on_select: false, + ..Default::default() + } +} 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..768e4e89d --- /dev/null +++ b/codex-rs/tui/src/app/jump_navigation.rs @@ -0,0 +1,106 @@ +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; +use codex_threadmessages::JumpCatalog; +use codex_threadmessages::JumpTarget; +use codex_threadmessages::JumpTargetKind; +use ratatui::text::Line; +use std::sync::Arc; + +pub(crate) fn build_jump_catalog(cells: &[Arc]) -> JumpCatalog { + 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; + } + + JumpCatalog::new(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 super::build_jump_catalog; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::PlainHistoryCell; + use crate::history_cell::UserHistoryCell; + use codex_threadmessages::JumpTargetKind; + use pretty_assertions::assert_eq; + use ratatui::text::Line; + use std::sync::Arc; + + #[test] + fn build_jump_catalog_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")], + true, + )), + ]; + + let catalog = build_jump_catalog(&cells); + + assert_eq!(catalog.len(), 2); + assert_eq!(catalog.targets[0].kind, JumpTargetKind::UserMessage); + assert_eq!(catalog.targets[0].preview, "first question"); + assert_eq!(catalog.targets[1].kind, JumpTargetKind::AgentMessage); + assert_eq!(catalog.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..44d14c7f3 --- /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::NONE) => { + KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage) + } + (KeyCode::Char('y'), KeyModifiers::NONE) => { + KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + } + (KeyCode::Char('r'), KeyModifiers::NONE) => { + 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_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::NONE)), + KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_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::NONE)), + KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_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::NONE)), + 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/loop_create.rs b/codex-rs/tui/src/app/loop_create.rs new file mode 100644 index 000000000..81e611189 --- /dev/null +++ b/codex-rs/tui/src/app/loop_create.rs @@ -0,0 +1,294 @@ +use super::App; +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use codex_loop::LoopContextMode; +use codex_loop::LoopResponseMode; +use codex_loop::LoopSecurityMode; +use codex_loop::LoopTriggerKind; + +const LOOP_CREATE_TRIGGER_VIEW_ID: &str = "fork-loop-create-trigger-panel"; +const LOOP_CREATE_RESPONSE_VIEW_ID: &str = "fork-loop-create-response-panel"; +const LOOP_CREATE_SECURITY_VIEW_ID: &str = "fork-loop-create-security-panel"; + +#[derive(Clone)] +pub(crate) struct LoopCreateDraft { + pub(crate) id: Option, + pub(crate) prompt: Option, + pub(crate) trigger_kind: Option, + pub(crate) context_mode: LoopContextMode, + pub(crate) response_mode: LoopResponseMode, + pub(crate) security_mode: LoopSecurityMode, + pub(crate) writable_roots_input: Option, +} + +impl LoopCreateDraft { + pub(crate) fn new(context_mode: LoopContextMode) -> Self { + Self { + id: None, + prompt: None, + trigger_kind: None, + context_mode, + response_mode: LoopResponseMode::default(), + security_mode: LoopSecurityMode::default(), + writable_roots_input: None, + } + } + + fn subtitle(&self) -> String { + match self.context_mode { + LoopContextMode::Embed => "Create loop agent · embed".to_string(), + LoopContextMode::Ephemeral => "Create loop agent · ephemeral".to_string(), + LoopContextMode::Persistent => "Create loop agent · persistent".to_string(), + } + } +} + +impl App { + pub(crate) fn start_loop_create_draft(&mut self, context_mode: LoopContextMode) { + self.loop_timers.create_draft = Some(LoopCreateDraft::new(context_mode)); + match context_mode { + LoopContextMode::Persistent => self.chat_widget.open_create_loop_id_prompt(), + LoopContextMode::Embed | LoopContextMode::Ephemeral => { + self.chat_widget.open_create_loop_prompt() + } + } + } + + pub(crate) fn save_create_loop_id(&mut self, id: String) { + let Some(draft) = self.loop_timers.create_draft.as_mut() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + let id = id.trim(); + if id.is_empty() { + self.chat_widget + .add_error_message("Loop id cannot be empty.".to_string()); + return; + } + if let Err(err) = codex_loop::validate_loop_id(id) { + self.chat_widget + .add_error_message(format!("Failed to create `/loop`: {err}")); + return; + } + draft.id = Some(id.to_string()); + self.chat_widget.open_create_loop_prompt(); + } + + pub(crate) fn save_create_loop_prompt(&mut self, prompt: String) { + let Some(draft) = self.loop_timers.create_draft.as_mut() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + let prompt = prompt.trim().to_string(); + if prompt.is_empty() { + self.chat_widget + .add_error_message("Loop prompt cannot be empty.".to_string()); + return; + } + draft.prompt = Some(prompt); + self.open_create_loop_draft_trigger_menu(); + } + + pub(crate) fn open_create_loop_draft_trigger_menu(&mut self) { + let Some(draft) = self.loop_timers.create_draft.as_ref() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(LOOP_CREATE_TRIGGER_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle: Some(draft.subtitle()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Timer".to_string(), + description: Some( + "Run this loop whenever an interval or cron schedule becomes due." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenCreateLoopTimerSchedulePrompt) + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Before Turn".to_string(), + description: Some( + "Run before a main-thread user turn is submitted.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::SaveCreateLoopBeforeTurnTrigger) + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "After Turn".to_string(), + description: Some( + "Run after the main-thread assistant final response completes.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::SaveCreateLoopAfterTurnTrigger) + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + } + + pub(crate) fn save_create_loop_timer_schedule(&mut self, schedule: String) { + let Some(draft) = self.loop_timers.create_draft.as_mut() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + let schedule = match codex_loop::parse_loop_schedule(schedule.trim()) { + Ok(schedule) => schedule, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to create `/loop`: {err}")); + return; + } + }; + draft.trigger_kind = Some(LoopTriggerKind::Timer { schedule }); + self.open_create_loop_response_mode_menu(); + } + + pub(crate) fn save_create_loop_before_turn_trigger(&mut self) { + let Some(draft) = self.loop_timers.create_draft.as_mut() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + draft.trigger_kind = Some(LoopTriggerKind::BeforeTurn); + self.open_create_loop_response_mode_menu(); + } + + pub(crate) fn save_create_loop_after_turn_trigger(&mut self) { + let Some(draft) = self.loop_timers.create_draft.as_mut() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + draft.trigger_kind = Some(LoopTriggerKind::AfterTurn); + self.open_create_loop_response_mode_menu(); + } + + pub(crate) fn open_create_loop_response_mode_menu(&mut self) { + let Some(draft) = self.loop_timers.create_draft.as_ref() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + let current_mode = draft.response_mode; + let items = LoopResponseMode::USER_SELECTABLE + .into_iter() + .map(|response_mode| SelectionItem { + name: response_mode.title().to_string(), + description: Some(response_mode.description().to_string()), + is_current: current_mode == response_mode, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveCreateLoopResponseMode { response_mode }) + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(LOOP_CREATE_RESPONSE_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle: Some(draft.subtitle()), + footer_hint: Some(standard_popup_hint_line()), + items, + on_cancel: Some(Box::new(|tx| { + tx.send(AppEvent::OpenCreateLoopDraftTriggerMenu) + })), + ..Default::default() + }); + } + + pub(crate) fn save_create_loop_response_mode(&mut self, response_mode: LoopResponseMode) { + let Some(draft) = self.loop_timers.create_draft.as_mut() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + draft.response_mode = response_mode; + self.open_create_loop_security_mode_menu(); + } + + pub(crate) fn open_create_loop_security_mode_menu(&mut self) { + let Some(draft) = self.loop_timers.create_draft.as_ref() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + let current_mode = draft.security_mode; + let items = [ + LoopSecurityMode::Inherited, + LoopSecurityMode::SpecifiedDirectory, + ] + .into_iter() + .map(|security_mode| SelectionItem { + name: security_mode.title().to_string(), + description: Some(match security_mode { + LoopSecurityMode::Inherited => { + "Use the main thread's current execution policy.".to_string() + } + LoopSecurityMode::SpecifiedDirectory => { + "Allow writes only inside explicitly configured directories.".to_string() + } + }), + is_current: current_mode == security_mode, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveCreateLoopSecurityMode { security_mode }) + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(LOOP_CREATE_SECURITY_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle: Some(draft.subtitle()), + footer_hint: Some(standard_popup_hint_line()), + items, + on_cancel: Some(Box::new(|tx| { + tx.send(AppEvent::OpenCreateLoopDraftResponseMode) + })), + ..Default::default() + }); + } + + pub(crate) fn save_create_loop_security_mode(&mut self, security_mode: LoopSecurityMode) { + let Some(draft) = self.loop_timers.create_draft.as_mut() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + draft.security_mode = security_mode; + if security_mode == LoopSecurityMode::SpecifiedDirectory { + self.chat_widget.open_create_loop_writable_roots_prompt(); + } else { + self.finalize_loop_create_draft(); + } + } + + pub(crate) fn save_create_loop_writable_roots(&mut self, writable_roots: String) { + let Some(draft) = self.loop_timers.create_draft.as_mut() else { + self.chat_widget + .add_error_message("Loop creation is no longer active.".to_string()); + return; + }; + draft.writable_roots_input = Some(writable_roots); + self.finalize_loop_create_draft(); + } +} diff --git a/codex-rs/tui/src/app/loop_timers.rs b/codex-rs/tui/src/app/loop_timers.rs new file mode 100644 index 000000000..a10751392 --- /dev/null +++ b/codex-rs/tui/src/app/loop_timers.rs @@ -0,0 +1,3583 @@ +use super::App; +use super::loop_create::LoopCreateDraft; +use crate::app_event::AppEvent; +use crate::app_event::LoopTimerTriggerSource; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::markdown::append_markdown; +use chrono::DateTime; +use chrono::Utc; +use codex_core::AuthManager; +use codex_core::CodexThread; +use codex_core::RolloutRecorder; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_core::content_items_to_text; +use codex_loop::LoopCommand; +use codex_loop::LoopContextMode; +use codex_loop::LoopMode; +use codex_loop::LoopResponseMode; +use codex_loop::LoopSecurityMode; +use codex_loop::LoopTriggerBinding; +use codex_loop::LoopTriggerKind; +use codex_loop::LoopTriggerPhase; +use codex_loop::PersistedLoopExecutionSettings; +use codex_loop::PersistedLoopTimer; +use codex_loop::PersistedLoopTimersFile; +use codex_loop::PersistedLoopTriggerQueuesFile; +use codex_loop::cwd_editor_text; +use codex_loop::effective_timer_schedule; +use codex_loop::format_timestamp; +use codex_loop::load_loop_timers; +use codex_loop::load_loop_trigger_queues; +use codex_loop::loop_execution_summary; +use codex_loop::loop_item_name; +use codex_loop::loop_timers_path; +use codex_loop::loop_trigger_queues_path; +use codex_loop::move_trigger_queue_entry; +use codex_loop::next_due_for_timer; +use codex_loop::next_trigger_binding_id; +use codex_loop::parse_loop_command; +use codex_loop::parse_loop_cwd; +use codex_loop::parse_loop_schedule; +use codex_loop::parse_loop_writable_roots; +use codex_loop::prompt_prefix; +use codex_loop::queue_entries_for_phase; +use codex_loop::sync_trigger_queues_with_timers; +use codex_loop::timer_descriptor; +use codex_loop::trigger_bindings; +use codex_loop::writable_roots_editor_text; +use codex_loop_runtime::build_loop_phase_input; +use codex_loop_runtime::build_loop_runtime_overrides; +use codex_protocol::ThreadId; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use ratatui::text::Line; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; + +pub(crate) mod after_turn_scheduler; + +use self::after_turn_scheduler::AfterTurnRoundResult; +use self::after_turn_scheduler::AfterTurnSchedulerAction; +use self::after_turn_scheduler::AfterTurnSchedulerState; + +const LOOP_TIMERS_VIEW_ID: &str = "fork-loop-timers-panel"; +const LOOP_CREATE_VIEW_ID: &str = "fork-loop-create-panel"; +const LOOP_TIMER_ACTIONS_VIEW_ID: &str = "fork-loop-timer-actions-panel"; +const LOOP_EXECUTION_VIEW_ID: &str = "fork-loop-execution-panel"; +const LOOP_TRIGGER_BINDINGS_VIEW_ID: &str = "fork-loop-trigger-bindings-panel"; +const LOOP_TRIGGER_CREATE_VIEW_ID: &str = "fork-loop-trigger-create-panel"; +const LOOP_TRIGGER_QUEUE_VIEW_ID: &str = "fork-loop-trigger-queue-panel"; +const LOOP_TRIGGER_PHASE_VIEW_ID: &str = "fork-loop-trigger-phase-panel"; +const LOOP_TRIGGER_ACTIONS_VIEW_ID: &str = "fork-loop-trigger-actions-panel"; +const LOOP_CONTEXT_BUDGET_TOKENS: usize = 2_000; + +pub(crate) struct LoopTimersState { + workspace_cwd: Option, + timers: BTreeMap, + trigger_queues: PersistedLoopTriggerQueuesFile, + pub(crate) create_draft: Option, + scheduler_tasks: HashMap>, + active_runs: HashMap, + pub(super) thread_history_cells: HashMap>>, + pub(super) after_turn_scheduler: AfterTurnSchedulerState, + after_turn_round_task: Option>, + after_turn_active_run: Arc>>, +} + +struct ActiveLoopRun { + thread_id: ThreadId, + thread: Arc, + listener_handle: JoinHandle<()>, +} + +struct ActiveLoopRunHandle { + thread_id: ThreadId, + thread: Arc, +} + +pub(crate) struct LoopTimerCompletion { + pub(crate) cells: Vec>, + pub(crate) followup_user_turn: Option, +} + +impl Default for LoopTimersState { + fn default() -> Self { + Self { + workspace_cwd: None, + timers: BTreeMap::new(), + trigger_queues: PersistedLoopTriggerQueuesFile::default(), + create_draft: None, + scheduler_tasks: HashMap::new(), + active_runs: HashMap::new(), + thread_history_cells: HashMap::new(), + after_turn_scheduler: AfterTurnSchedulerState::default(), + after_turn_round_task: None, + after_turn_active_run: Arc::new(Mutex::new(None)), + } + } +} + +struct StartedLoopThread { + thread_id: ThreadId, + thread: Arc, + rollout_path: Option, +} + +struct LoopHookOutput { + loop_id: String, + response_mode: LoopResponseMode, + message: Option, + action: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LoopReplySource { + loop_id: String, + action: Option, +} + +impl LoopReplySource { + fn new(loop_id: String, action: Option) -> Self { + Self { loop_id, action } + } + + fn hint(&self) -> String { + match self + .action + .as_deref() + .map(str::trim) + .filter(|action| !action.is_empty()) + { + Some(action) => format!("{} · {}", self.loop_id, prompt_prefix(action)), + None => self.loop_id.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LoopMirroredUserTurn { + pub(crate) text: String, + pub(crate) source: LoopReplySource, +} + +impl App { + fn loop_trigger_queues_panel_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(LOOP_TRIGGER_QUEUE_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle: Some("Trigger Queue".to_string()), + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some( + loop_trigger_queues_path(self.config.cwd.as_path()) + .display() + .to_string(), + ), + initial_selected_idx, + items: LoopTriggerPhase::USER_SELECTABLE + .into_iter() + .map(|phase| SelectionItem { + name: phase.title().to_string(), + description: Some(phase.description().to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenLoopTriggerQueuePhase { phase }) + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenLoopTimersPanel))), + ..Default::default() + } + } + + pub(crate) fn open_loop_trigger_queues_panel(&mut self) { + self.ensure_loop_timers_loaded(); + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(LOOP_TRIGGER_QUEUE_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + LOOP_TRIGGER_QUEUE_VIEW_ID, + self.loop_trigger_queues_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.loop_trigger_queues_panel_params(initial_selected_idx)); + } + } + + pub(crate) fn open_loop_trigger_queue_phase_panel(&mut self, phase: LoopTriggerPhase) { + self.ensure_loop_timers_loaded(); + let entries = queue_entries_for_phase(&self.loop_timers.trigger_queues, phase) + .iter() + .filter_map(|entry| { + let timer = self.loop_timers.timers.get(&entry.loop_id)?; + let binding = trigger_bindings(timer) + .into_iter() + .find(|binding| binding.id == entry.binding_id)?; + Some(SelectionItem { + name: format!("{} / {}", loop_item_name(timer), binding.selection_name()), + description: Some(prompt_prefix(&timer.prompt)), + actions: vec![Box::new({ + let loop_id = entry.loop_id.clone(); + let binding_id = entry.binding_id.clone(); + move |tx| { + tx.send(AppEvent::OpenLoopTriggerQueueEntryActions { + phase, + loop_id: loop_id.clone(), + binding_id: binding_id.clone(), + }) + } + })], + dismiss_on_select: true, + is_disabled: !binding.enabled || !timer.enabled, + ..Default::default() + }) + }) + .collect::>(); + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(LOOP_TRIGGER_PHASE_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle: Some(format!("Trigger Queue · {}", phase.title())), + footer_hint: Some(standard_popup_hint_line()), + items: if entries.is_empty() { + vec![SelectionItem { + name: "No triggers in this queue".to_string(), + description: Some( + "Add triggers inside a loop, then reorder them here across loops." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }] + } else { + entries + }, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenLoopTriggerQueuesPanel))), + ..Default::default() + }); + } + + pub(crate) fn open_loop_trigger_queue_entry_actions( + &mut self, + phase: LoopTriggerPhase, + loop_id: String, + binding_id: String, + ) { + self.ensure_loop_timers_loaded(); + let Some(timer) = self.loop_timers.timers.get(&loop_id) else { + self.open_loop_trigger_queue_phase_panel(phase); + return; + }; + let Some(binding) = trigger_bindings(timer) + .into_iter() + .find(|binding| binding.id == binding_id) + else { + self.open_loop_trigger_queue_phase_panel(phase); + return; + }; + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(LOOP_TRIGGER_ACTIONS_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle: Some(format!( + "Trigger Queue · {} / {}", + loop_item_name(timer), + binding.selection_name() + )), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Move Up".to_string(), + description: Some("Run this trigger earlier within the queue.".to_string()), + actions: vec![Box::new({ + let loop_id = loop_id.clone(); + let binding_id = binding_id.clone(); + move |tx| { + tx.send(AppEvent::MoveLoopTriggerQueueEntry { + phase, + loop_id: loop_id.clone(), + binding_id: binding_id.clone(), + move_up: true, + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Move Down".to_string(), + description: Some("Run this trigger later within the queue.".to_string()), + actions: vec![Box::new({ + let loop_id = loop_id.clone(); + move |tx| { + tx.send(AppEvent::MoveLoopTriggerQueueEntry { + phase, + loop_id: loop_id.clone(), + binding_id: binding_id.clone(), + move_up: false, + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Open Loop".to_string(), + description: Some( + "Jump back to this loop's configuration and triggers.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenLoopTimerActions { + timer_id: loop_id.clone(), + }) + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + on_cancel: Some(Box::new(move |tx| { + tx.send(AppEvent::OpenLoopTriggerQueuePhase { phase }) + })), + ..Default::default() + }); + } + + pub(crate) fn move_loop_trigger_queue_entry( + &mut self, + phase: LoopTriggerPhase, + loop_id: String, + binding_id: String, + move_up: bool, + ) { + self.ensure_loop_timers_loaded(); + let moved = move_trigger_queue_entry( + &mut self.loop_timers.trigger_queues, + phase, + &loop_id, + &binding_id, + if move_up { + codex_loop::QueueMoveDirection::Up + } else { + codex_loop::QueueMoveDirection::Down + }, + ); + if moved && let Err(err) = self.persist_loop_timers() { + self.chat_widget + .add_error_message(format!("Failed to update trigger queue: {err}")); + } + self.open_loop_trigger_queue_phase_panel(phase); + } + + fn loop_timers_panel_params(&self, initial_selected_idx: Option) -> SelectionViewParams { + let path = loop_timers_path(self.config.cwd.as_path()); + let subtitle = Some(format!( + "{} loop agent(s) configured for {}.", + self.loop_timers.timers.len(), + self.config.cwd.display() + )); + + let mut items = self + .loop_timers + .timers + .values() + .map(|timer| { + loop_timer_selection_item( + timer, + self.loop_timers.active_runs.contains_key(&timer.id), + ) + }) + .collect::>(); + + items.insert( + 0, + SelectionItem { + name: "Trigger Queue".to_string(), + description: Some( + "Reorder cross-loop execution for timer, before-turn, and after-turn triggers." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenLoopTriggerQueuesPanel))], + dismiss_on_select: true, + ..Default::default() + }, + ); + + items.insert( + 0, + SelectionItem { + name: "Create Loop Agent".to_string(), + description: Some( + "Create an embed, ephemeral, or persistent `/loop` from a guided form." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenCreateLoopTimerMenu))], + dismiss_on_select: true, + ..Default::default() + }, + ); + + if self.loop_timers.timers.is_empty() { + items.push(SelectionItem { + name: "No loop agents yet".to_string(), + description: Some( + "Use Create Loop Agent or `/loop 5m ` to add one.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(LOOP_TIMERS_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(path.display().to_string()), + initial_selected_idx, + items, + ..Default::default() + } + } + + pub(crate) fn open_loop_timers_panel(&mut self) { + self.ensure_loop_timers_loaded(); + + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(LOOP_TIMERS_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + LOOP_TIMERS_VIEW_ID, + self.loop_timers_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.loop_timers_panel_params(initial_selected_idx)); + } + } + + pub(crate) fn open_create_loop_timer_menu(&mut self) { + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(LOOP_CREATE_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle: Some("Create loop agent".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Embed".to_string(), + description: Some( + "Run directly in the main-thread execution path.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::StartCreateLoopDraft { + context_mode: LoopContextMode::Embed, + }) + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Ephemeral".to_string(), + description: Some( + "Run in a hidden thread that is discarded after each trigger.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::StartCreateLoopDraft { + context_mode: LoopContextMode::Ephemeral, + }) + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Persistent".to_string(), + description: Some( + "Run in a hidden thread with a stable id and a retained rollout." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::StartCreateLoopDraft { + context_mode: LoopContextMode::Persistent, + }) + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenLoopTimersPanel))), + ..Default::default() + }); + } + + fn loop_execution_panel_params( + &self, + timer_id: &str, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let Some(timer) = self.loop_timers.timers.get(timer_id) else { + return SelectionViewParams::default(); + }; + let items = vec![ + SelectionItem { + name: "Working Directory".to_string(), + description: Some(loop_execution_summary(&timer.execution, self.config.cwd.as_path())), + actions: vec![Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::OpenEditLoopTimerCwd { + timer_id: timer_id.clone(), + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use Session Working Directory".to_string(), + description: Some( + "Clear the per-loop cwd override and inherit the main thread working directory." + .to_string(), + ), + is_disabled: timer.execution.cwd.is_none(), + actions: vec![Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::ResetLoopTimerCwd { + timer_id: timer_id.clone(), + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Writable Directories".to_string(), + description: Some( + "Restrict loop file writes to specific directories. Leave empty to inherit the session scope." + .to_string(), + ), + actions: vec![Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::OpenEditLoopWritableRoots { + timer_id: timer_id.clone(), + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use Session Writable Scope".to_string(), + description: Some( + "Clear the per-loop writable-directory override and inherit the main thread sandbox scope." + .to_string(), + ), + is_disabled: timer.execution.writable_roots.is_empty(), + actions: vec![Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::ResetLoopWritableRoots { + timer_id: timer_id.clone(), + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + SelectionViewParams { + view_id: Some(LOOP_EXECUTION_VIEW_ID), + title: Some("Loop Execution".to_string()), + subtitle: Some(format!("Execution settings · {}", timer_descriptor(timer))), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + on_cancel: Some(Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::OpenLoopTimerActions { + timer_id: timer_id.clone(), + }) + } + })), + ..Default::default() + } + } + + pub(crate) fn open_loop_execution_panel(&mut self, timer_id: String) { + self.ensure_loop_timers_loaded(); + + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(LOOP_EXECUTION_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + LOOP_EXECUTION_VIEW_ID, + self.loop_execution_panel_params(&timer_id, initial_selected_idx), + ) { + self.chat_widget.show_selection_view( + self.loop_execution_panel_params(&timer_id, initial_selected_idx), + ); + } + } + + fn refresh_loop_timers_panel_if_active(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(LOOP_TIMERS_VIEW_ID); + let _ = self.chat_widget.replace_selection_view_if_active( + LOOP_TIMERS_VIEW_ID, + self.loop_timers_panel_params(initial_selected_idx), + ); + } + + pub(crate) fn create_loop_timer(&mut self, spec: String) { + self.ensure_loop_timers_loaded(); + + let parsed = match parse_loop_command(spec.trim()) { + Ok(parsed) => parsed, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to create `/loop`: {err}")); + return; + } + }; + + let now = Utc::now(); + let (timer_id, message) = match parsed { + LoopCommand::Focus { id } => { + if self.loop_timers.timers.contains_key(&id) { + self.open_loop_timer_actions(id); + } else { + self.chat_widget.add_error_message(format!( + "Unknown loop `{id}`. Create it with `/loop {id}