Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae13397e5f | |||
| 3d537bba59 | |||
| 67f9db8bc8 | |||
| 2c2afac192 | |||
| 733234c303 | |||
| 039661185d | |||
| 4b6779febb | |||
| f914095c1d | |||
| 269dffacb6 | |||
| 55459d5df9 | |||
| 2fbd660f55 | |||
| 275465d862 | |||
| 93ce9a957e | |||
| a101840bbc | |||
| 5689ef33ee | |||
| 9a0e24213b | |||
| 1732faf3f3 | |||
| bb5de03e50 | |||
| fd9a7d94ff | |||
| 8e02d27bc3 | |||
| 11bd5fbb5e | |||
| 7560752a23 | |||
| 45424e0739 | |||
| 1bc828088c | |||
| 9d54826ea5 | |||
| 403095cbf8 | |||
| 5baab2fa88 | |||
| 2e0098ed1f | |||
| 5e02fd192a | |||
| 3da0e9f4e0 | |||
| 9a3d4dea54 | |||
| 32f0d78ebd | |||
| fe421c98a7 | |||
| 0e940538c4 | |||
| 9fc0c382be | |||
| 636fb6a774 | |||
| b86ae8e121 | |||
| 0b0fc3f2f9 | |||
| ccaa42e926 | |||
| adffcbbc4b | |||
| ef60c1f874 | |||
| d00c5bd9b1 | |||
| b08eb50f2e | |||
| b8fd98ef7b | |||
| 1ed6b49671 | |||
| 33ec51372b | |||
| b2de85a996 | |||
| 044ca76f36 | |||
| 5520a5e00d | |||
| d68aabe03c | |||
| 179774eb63 | |||
| 9bc3425913 | |||
| 752d3eb3d6 | |||
| 4ac3dfc5be | |||
| 741f2d29e4 | |||
| b551f669d6 | |||
| ddc5f6ee60 | |||
| 473941e322 | |||
| 1f575ba8de | |||
| 8f63f3271b | |||
| 9df23f7aeb | |||
| c6d1f7d5e0 | |||
| 05eaa253d1 | |||
| 699809de6e | |||
| 4f05fbd0ac | |||
| 537620909c | |||
| ed9369474f | |||
| 1fceeeb754 | |||
| 71a3437168 | |||
| 86726e0ab5 | |||
| 03c7907966 | |||
| adcf83ccb6 | |||
| 70ee54bdba | |||
| 036e0b9d29 | |||
| fb579783ce | |||
| fabdc7d25b | |||
| ddfa872809 | |||
| 21f71ddfdd | |||
| 1fc4b283b8 | |||
| 83b7ff409f | |||
| a447db22cb | |||
| ce00d35f0d | |||
| 8aaf72c91a | |||
| 65021dc1e8 | |||
| 6d277c4fc0 | |||
| 5de71a1877 | |||
| fcf5a902af | |||
| ba3692130d | |||
| 57df36a1ee | |||
| fc0e2e99c6 | |||
| c2439c6fd7 | |||
| 901aa8d12a | |||
| 2aba5079d4 | |||
| dc10a22147 | |||
| 08b20956f2 | |||
| 39c986cb06 | |||
| c8c4f81826 | |||
| 6c2daae4a9 | |||
| 73abf26b8e | |||
| b9d53f0a96 | |||
| 8f194d6f9d | |||
| 204e99d5c6 | |||
| d41f491e37 | |||
| 4e13ff5b82 | |||
| bee2b7518e | |||
| 849af0674f | |||
| 244ded1ed9 | |||
| e811f35f23 | |||
| c6b627cfc6 | |||
| c4dd03c47c | |||
| c118266e59 | |||
| d0c4317757 | |||
| 35689fc2a6 | |||
| f79e196428 | |||
| f75c07ba56 | |||
| bc3dc80200 | |||
| 75b6e71b18 | |||
| 84d1753c84 | |||
| ce6dba9c88 | |||
| 10e5cf6059 | |||
| 8f73e26a74 | |||
| a31e3f67ac | |||
| a736d3aeed | |||
| 32f58f1e16 | |||
| 321a766e59 |
@@ -146,7 +146,7 @@ streaming, harness.
|
||||
|
||||
```bash
|
||||
# Unit tests (use --frozen; the cwsandbox extra is unsatisfiable on public PyPI here)
|
||||
uv run --frozen --extra dev python -m pytest \
|
||||
uv run --frozen --group test python -m pytest \
|
||||
tests/inner/test_antigravity_executor.py \
|
||||
tests/inner/test_antigravity_harness.py \
|
||||
tests/runtime/test_antigravity_spawn_env.py \
|
||||
|
||||
@@ -157,7 +157,7 @@ already has complementary CUJ coverage — use both:
|
||||
- **Deeper end-to-end journeys → `tests/e2e/test_journey_*.py`** (first session
|
||||
to code, resume/disconnect, fork/explore, file upload, collaboration, …).
|
||||
Run a slice with the project's gated runner, e.g.
|
||||
`uv run --frozen --extra dev python -m pytest tests/e2e/test_journey_first_session_to_code.py -q`.
|
||||
`uv run --frozen --group test python -m pytest tests/e2e/test_journey_first_session_to_code.py -q`.
|
||||
- **Reusable PTY helpers** live in `tests/e2e/omnigent/_pexpect_harness.py`
|
||||
(`spawn_omnigent_run`, `wait_for_ready`, `submit_prompt`, `await_turn_complete`,
|
||||
`clean_exit`) and the snapshot comparator in `tests/e2e/omnigent/_snapshot.py`
|
||||
|
||||
@@ -20,8 +20,8 @@ the unit tests.
|
||||
|
||||
1. **You're on the branch you want to test.** The copilot harness is an
|
||||
optional extra — install it (without disturbing other extras) with
|
||||
`uv sync --frozen --extra dev --extra copilot`. NB: a bare
|
||||
`uv run --frozen --extra dev` re-syncs the venv and **prunes** the copilot
|
||||
`uv sync --frozen --group test --extra copilot`. NB: a bare
|
||||
`uv run --frozen --group test` re-syncs the venv and **prunes** the copilot
|
||||
SDK; for live testing call `.venv/bin/omni` / `.venv/bin/python` directly and
|
||||
avoid `uv run` mid-session.
|
||||
2. **The SDK is installed:**
|
||||
@@ -169,7 +169,7 @@ final answer lands server-side — read it over the AP API
|
||||
- **Spawn env:** `_build_copilot_spawn_env` in `omnigent/runtime/workflow.py`
|
||||
|
||||
```bash
|
||||
uv run --frozen --extra dev python -m pytest \
|
||||
uv run --frozen --group test python -m pytest \
|
||||
tests/inner/test_copilot_executor.py \
|
||||
tests/inner/test_copilot_harness.py \
|
||||
tests/runtime/test_copilot_spawn_env.py \
|
||||
|
||||
@@ -128,12 +128,12 @@ that works, the full stack is good: key, egress, bridge, harness.
|
||||
|
||||
```bash
|
||||
# Unit tests (use --frozen; the cwsandbox extra is unsatisfiable on public PyPI here)
|
||||
uv run --frozen --extra dev python -m pytest \
|
||||
uv run --frozen --group test python -m pytest \
|
||||
tests/inner/test_cursor_executor.py \
|
||||
tests/runtime/test_cursor_spawn_env.py \
|
||||
tests/onboarding/test_cursor_auth.py -q
|
||||
# Gated end-to-end harness test
|
||||
uv run --frozen --extra dev python -m pytest tests/e2e/omnigent/test_per_harness_cursor.py -q
|
||||
uv run --frozen --group test python -m pytest tests/e2e/omnigent/test_per_harness_cursor.py -q
|
||||
```
|
||||
|
||||
## Bug-bash (fan out)
|
||||
|
||||
@@ -203,7 +203,7 @@ side effects.
|
||||
|
||||
```bash
|
||||
# Existing pytest e2e for polly (mock-LLM) — complementary to this skill:
|
||||
uv run --frozen --extra dev python -m pytest \
|
||||
uv run --frozen --group test python -m pytest \
|
||||
tests/e2e/test_polly_e2e.py \
|
||||
tests/e2e/test_polly_cost_advisor_e2e.py \
|
||||
tests/e2e/test_polly_subagent_model_e2e.py -q
|
||||
|
||||
@@ -19,7 +19,7 @@ micro-benchmarks (not concurrency), that is a different tool: `dev/benchmarks/`.
|
||||
## 1. Ensure deps (repo checkout)
|
||||
|
||||
```bash
|
||||
pip install -e '.[loadtest,dev,agents-sdk]' # or: uv sync --extra loadtest --extra dev --extra agents-sdk
|
||||
uv sync --extra loadtest --extra agents-sdk
|
||||
```
|
||||
|
||||
Run with that same interpreter (e.g. `.venv/bin/python`), from the repo root.
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
web/electron/icons/AppIcon.icon/** binary -merge
|
||||
|
||||
# Protobuf bindings regenerated by scripts/gen_routing_pb2.py from the .proto
|
||||
# schema. Mark them generated so review/code-quality tooling skips them (ruff
|
||||
# and mypy already exclude them in pyproject.toml); the protoc output isn't
|
||||
# schema. Mark them generated so review/code-quality tooling skips them (Ruff
|
||||
# excludes them and Pyrefly ignores generated-code errors); the protoc output isn't
|
||||
# hand-editable, so its unused-import/global artifacts are expected.
|
||||
omnigent/api/**/*_pb2.py linguist-generated=true
|
||||
omnigent/api/**/*_pb2.pyi linguist-generated=true
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: "Run e2e compat smoke tests"
|
||||
description: >
|
||||
Run @pytest.mark.compat_smoke tests from tests/e2e/ in one configuration:
|
||||
either the server or the runner subprocess is pinned to an older released
|
||||
build while the other side stays on the checked-out code. Exactly one of
|
||||
server_version / runner_version must be set.
|
||||
|
||||
Reuses the same install steps as .github/actions/e2e-run so the two
|
||||
never drift on Python/uv/binary-dep setup. Unlike e2e-run this action
|
||||
is not sharded and runs only the compat_smoke marker, keeping wall-clock
|
||||
time under ~15 minutes.
|
||||
|
||||
inputs:
|
||||
server_version:
|
||||
description: >
|
||||
Release tag for the OLD server build (e.g. v0.9.0).
|
||||
Set this for Config 1 (new runner, old server). Leave empty for Config 2.
|
||||
required: false
|
||||
default: ""
|
||||
runner_version:
|
||||
description: >
|
||||
Release tag for the OLD runner build (e.g. v0.9.0).
|
||||
Set this for Config 2 (new server, old runner). Leave empty for Config 1.
|
||||
required: false
|
||||
default: ""
|
||||
artifact_suffix:
|
||||
description: >
|
||||
Appended to uploaded-artifact names to keep them unique across jobs
|
||||
(e.g. "-config1"). Default empty — fine for single-run cases.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Configure environment
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "OMNIGENT_SKIP_WEB_UI=true"
|
||||
echo "ANTHROPIC_API_KEY="
|
||||
echo "OPENAI_API_KEY="
|
||||
echo "CODEX="
|
||||
echo "CLAUDE_CODE="
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and test dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install binary dependencies
|
||||
working-directory: .github/ci-deps
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get install -y tmux bubblewrap
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
npm install --ignore-scripts
|
||||
node node_modules/@anthropic-ai/claude-code/install.cjs
|
||||
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: "Build pinned old server (Config 2: new runner, old server)"
|
||||
if: ${{ inputs.server_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
|
||||
run: |
|
||||
tag="$SERVER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid server_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/server-src"
|
||||
venv="$RUNNER_TEMP/server-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: "Build pinned old runner/host (Config 1: new server, old runner)"
|
||||
if: ${{ inputs.runner_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
RUNNER_VERSION_INPUT: ${{ inputs.runner_version }}
|
||||
run: |
|
||||
tag="$RUNNER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid runner_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/runner-src"
|
||||
venv="$RUNNER_TEMP/runner-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_RUNNER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_RUNNER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run compat smoke tests
|
||||
shell: bash
|
||||
env:
|
||||
E2E_TMP_BASE: /tmp/omnigent-compat-smoke-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p "$E2E_TMP_BASE"
|
||||
uv run pytest tests/e2e/ \
|
||||
-m compat_smoke \
|
||||
-v --tb=long --showlocals --log-level=INFO \
|
||||
--timeout=120 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$E2E_TMP_BASE" \
|
||||
--junitxml="$E2E_TMP_BASE/junit.xml"
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: ${{ failure() || cancelled() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: compat-smoke-logs-${{ github.run_id }}${{ inputs.artifact_suffix }}
|
||||
path: |
|
||||
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/server.log
|
||||
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/runner.log
|
||||
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/.omnigent/logs/**/*.log
|
||||
/tmp/omnigent-compat-smoke-${{ github.run_id }}/junit.xml
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
include-hidden-files: true
|
||||
@@ -0,0 +1,227 @@
|
||||
name: "Run UI compat tests (smoke or full suite)"
|
||||
description: >
|
||||
Run tests/e2e_ui/ in one of two cross-version configurations:
|
||||
|
||||
Config A — new SPA + new runner, old server (server_version set):
|
||||
The server subprocess is pinned to the released tag while the SPA is
|
||||
built from HEAD. Tests the common deploy ordering where the server
|
||||
lags behind the frontend.
|
||||
|
||||
Config B — old SPA, new server + new runner (ui_version set):
|
||||
The SPA is built from the released tag's web/ source and the HEAD
|
||||
server is pointed at it via OMNIGENT_WEB_UI_DIST. Tests cached-SPA
|
||||
scenarios where a user's browser has an older bundle after a server
|
||||
upgrade.
|
||||
|
||||
Exactly one of server_version / ui_version must be set.
|
||||
|
||||
Two run modes:
|
||||
- full_suite=false (default): run only @pytest.mark.compat_smoke tests.
|
||||
- full_suite=true: run the complete tests/e2e_ui/ suite, sharded.
|
||||
|
||||
inputs:
|
||||
server_version:
|
||||
description: >
|
||||
Release tag for the OLD server (e.g. v0.9.0). SPA and runner stay
|
||||
on HEAD. Mutually exclusive with ui_version.
|
||||
required: false
|
||||
default: ""
|
||||
ui_version:
|
||||
description: >
|
||||
Release tag whose web/ source is used to build the OLD SPA (e.g.
|
||||
v0.9.0). Server and runner stay on HEAD; OMNIGENT_WEB_UI_DIST is
|
||||
set to the old built bundle. Mutually exclusive with server_version.
|
||||
required: false
|
||||
default: ""
|
||||
full_suite:
|
||||
description: >
|
||||
"true" = run the full tests/e2e_ui/ suite with sharding (overnight matrix).
|
||||
"false" (default) = run only @pytest.mark.compat_smoke tests (PR gate).
|
||||
required: false
|
||||
default: "false"
|
||||
shard_id:
|
||||
description: "0-based shard index (only used when full_suite=true)."
|
||||
required: false
|
||||
default: "0"
|
||||
num_shards:
|
||||
description: "Total shard count (only used when full_suite=true)."
|
||||
required: false
|
||||
default: "1"
|
||||
artifact_suffix:
|
||||
description: >
|
||||
Appended to uploaded-artifact names (e.g. "-ui-config"). Default empty.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Configure environment
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "OMNIGENT_SKIP_WEB_UI=true"
|
||||
echo "ANTHROPIC_API_KEY="
|
||||
echo "OPENAI_API_KEY="
|
||||
echo "CODEX="
|
||||
echo "CLAUDE_CODE="
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up pnpm + Node
|
||||
uses: ./.github/actions/setup-pnpm
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and test dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install bubblewrap and tmux
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get install -y bubblewrap tmux
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
shell: bash
|
||||
run: uv run playwright install --with-deps chromium
|
||||
|
||||
- name: Build HEAD SPA
|
||||
# Always build the HEAD SPA so the built_spa fixture's tombstone assertion
|
||||
# passes. In Config B OMNIGENT_WEB_UI_DIST is then set to the old bundle,
|
||||
# so the server serves that instead — but the HEAD build must exist for the
|
||||
# fixture's structural check.
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --filter web
|
||||
pnpm --filter web run build
|
||||
|
||||
- name: "Build pinned old server (Config A)"
|
||||
if: ${{ inputs.server_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
|
||||
run: |
|
||||
tag="$SERVER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid server_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/ui-server-src"
|
||||
venv="$RUNNER_TEMP/ui-server-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: "Build old SPA from release tag (Config B: old SPA / new server)"
|
||||
# Check out the old tag's web/ source into a temp dir, build it there,
|
||||
# then set OMNIGENT_WEB_UI_DIST so the HEAD server serves that bundle.
|
||||
if: ${{ inputs.ui_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
UI_VERSION_INPUT: ${{ inputs.ui_version }}
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
tag="$UI_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid ui_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/ui-spa-src"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
# Build the old SPA in its own directory. pnpm install uses the
|
||||
# old lock file; the build output lands in web/dist/ inside src.
|
||||
cd "$src"
|
||||
pnpm install --frozen-lockfile --filter web
|
||||
pnpm --filter web run build
|
||||
# vite.config.ts writes to ../omnigent/server/static/web-ui relative
|
||||
# to web/ — resolve to the absolute path inside the old checkout.
|
||||
built=$(cd web && node -e "const p=require('./vite.config.ts')" 2>/dev/null \
|
||||
|| echo "$src/omnigent/server/static/web-ui")
|
||||
# Fall back to checking both known locations.
|
||||
if [ -d "$src/omnigent/server/static/web-ui" ]; then
|
||||
built="$src/omnigent/server/static/web-ui"
|
||||
elif [ -d "$src/web/dist" ]; then
|
||||
built="$src/web/dist"
|
||||
else
|
||||
echo "Could not locate built SPA under $src" >&2; exit 1
|
||||
fi
|
||||
# Set OMNIGENT_WEB_UI_DIST so the HEAD server serves this old bundle.
|
||||
# The HEAD SPA is still built above (built_spa fixture needs it for the
|
||||
# tombstone assertion); the server uses OMNIGENT_WEB_UI_DIST to override
|
||||
# which bundle it actually mounts.
|
||||
echo "OMNIGENT_WEB_UI_DIST=$built" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run UI compat tests
|
||||
shell: bash
|
||||
env:
|
||||
FULL_SUITE: ${{ inputs.full_suite }}
|
||||
SHARD_ID: ${{ inputs.shard_id }}
|
||||
NUM_SHARDS: ${{ inputs.num_shards }}
|
||||
E2E_TMP_BASE: /tmp/omnigent-compat-ui-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p "$E2E_TMP_BASE"
|
||||
if [[ "$FULL_SUITE" == "true" ]]; then
|
||||
uv run pytest tests/e2e_ui/ \
|
||||
-m "not visual and not nightly" \
|
||||
--ui-skip-build \
|
||||
--splits="$NUM_SHARDS" \
|
||||
--group="$((SHARD_ID + 1))" \
|
||||
--tracing=retain-on-failure \
|
||||
--screenshot=only-on-failure \
|
||||
-v --tb=long --showlocals --log-level=INFO \
|
||||
--timeout=180 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$E2E_TMP_BASE" \
|
||||
--junitxml="$E2E_TMP_BASE/junit.xml" \
|
||||
|| { rc=$?; [ "$rc" -eq 5 ] && echo "::notice::No tests collected in this shard; treating as a pass." || exit "$rc"; }
|
||||
else
|
||||
uv run pytest tests/e2e_ui/ \
|
||||
-m compat_smoke \
|
||||
--ui-skip-build \
|
||||
-v --tb=long --showlocals --log-level=INFO \
|
||||
--timeout=120 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$E2E_TMP_BASE" \
|
||||
--junitxml="$E2E_TMP_BASE/junit.xml"
|
||||
fi
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: ${{ failure() || cancelled() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: compat-ui-smoke-logs-${{ github.run_id }}${{ inputs.artifact_suffix }}
|
||||
path: |
|
||||
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/server.log
|
||||
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/runner.log
|
||||
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/.omnigent/logs/**/*.log
|
||||
/tmp/omnigent-compat-ui-${{ github.run_id }}/junit.xml
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
include-hidden-files: true
|
||||
@@ -81,9 +81,9 @@ runs:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and dev dependencies
|
||||
- name: Install project and test dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install binary dependencies
|
||||
# npm install against .github/ci-deps/package.json with --ignore-scripts
|
||||
|
||||
@@ -71,9 +71,9 @@ runs:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and dev dependencies
|
||||
- name: Install project and test dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install binary dependencies
|
||||
# Mirrors e2e.yml. --ignore-scripts blocks npm postinstall hooks; we run
|
||||
|
||||
@@ -58,7 +58,8 @@ runs:
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.workdir }}
|
||||
run: uv sync --extra all --extra dev
|
||||
# Current callers are tools-less prose/JSON agents; repository checks never run.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
shell: bash
|
||||
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Emit the UI backwards-compat matrix on $GITHUB_OUTPUT as `ui_matrix`.
|
||||
#
|
||||
# The UI matrix is server-only: for each final (non-prerelease) release tag
|
||||
# at or above the backcompat floor, emit one cell per shard where the server
|
||||
# is that release and the SPA + runner are both main. The runner axis is
|
||||
# omitted because the SPA is always served by the server binary in production,
|
||||
# so "new SPA vs old runner" is not a meaningful compat scenario for the UI.
|
||||
#
|
||||
# Env in:
|
||||
# VERSIONS optional comma-separated override (e.g. "main,v0.9.0").
|
||||
# When set, only release tokens (non-"main") become cells.
|
||||
# NUM_SHARDS e2e_ui shard count per cell (default 3, mirrors e2e-ui.yml).
|
||||
# Out (GITHUB_OUTPUT):
|
||||
# ui_matrix={"include":[{"server":..,"shard_id":..,"num_shards":..}, ...]}
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
_valid_version() {
|
||||
[ "$1" = "main" ] || [[ "$1" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]
|
||||
}
|
||||
|
||||
MIN_VERSION="${BACKCOMPAT_MIN_VERSION:-0.9.0}"
|
||||
MIN_VERSION="${MIN_VERSION#v}"
|
||||
|
||||
_below_floor() {
|
||||
[ "$1" = "main" ] && return 1
|
||||
local v="${1#v}"
|
||||
[ "$v" = "$MIN_VERSION" ] && return 1
|
||||
[ "$(printf '%s\n%s\n' "$v" "$MIN_VERSION" | sort -V | head -1)" = "$v" ]
|
||||
}
|
||||
|
||||
raw=()
|
||||
if [ -n "${VERSIONS:-}" ]; then
|
||||
IFS=',' read -ra raw <<<"$VERSIONS"
|
||||
else
|
||||
raw=()
|
||||
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])(rc|dev|pre)[0-9]')
|
||||
fi
|
||||
|
||||
# Collect only release tokens (skip "main" — "main vs main" is the normal gate).
|
||||
V=()
|
||||
for v in "${raw[@]}"; do
|
||||
v="${v#"${v%%[![:space:]]*}"}"
|
||||
v="${v%"${v##*[![:space:]]}"}"
|
||||
[ -z "$v" ] && continue
|
||||
[ "$v" = "main" ] && continue
|
||||
if ! _valid_version "$v"; then
|
||||
echo "skipping invalid version token: '$v'" >&2; continue
|
||||
fi
|
||||
if _below_floor "$v"; then
|
||||
echo "skipping '$v': below UI backcompat floor $MIN_VERSION" >&2; continue
|
||||
fi
|
||||
V+=("$v")
|
||||
done
|
||||
|
||||
num_shards="${NUM_SHARDS:-3}"
|
||||
|
||||
# Each release tag produces 2 × num_shards jobs: one Config A cell
|
||||
# (server=tag) and one Config B cell (ui=tag). Cap at 256 total.
|
||||
max_ui=256
|
||||
while [ "${#V[@]}" -gt 0 ] && [ "$(( ${#V[@]} * 2 * num_shards ))" -gt "$max_ui" ]; do
|
||||
dropped="${V[${#V[@]} - 1]}"
|
||||
unset 'V[${#V[@]}-1]'
|
||||
V=("${V[@]}")
|
||||
echo "ui-matrix cap: dropped oldest version '$dropped' to keep UI jobs <= $max_ui" >&2
|
||||
done
|
||||
|
||||
items=()
|
||||
for v in "${V[@]}"; do
|
||||
# Config A: new SPA (HEAD), old server
|
||||
for ((i = 0; i < num_shards; i++)); do
|
||||
items+=("{\"server\":\"$v\",\"ui\":\"\",\"config\":\"A\",\"shard_id\":$i,\"num_shards\":$num_shards}")
|
||||
done
|
||||
# Config B: old SPA (tag), new server (HEAD)
|
||||
for ((i = 0; i < num_shards; i++)); do
|
||||
items+=("{\"server\":\"\",\"ui\":\"$v\",\"config\":\"B\",\"shard_id\":$i,\"num_shards\":$num_shards}")
|
||||
done
|
||||
done
|
||||
|
||||
json=$(IFS=,; echo "${items[*]:-}")
|
||||
echo "ui_matrix={\"include\":[$json]}" >>"${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
echo "versions: ${V[*]:-(none)}; UI jobs: ${#items[@]} (${#V[@]} tags × 2 configs × $num_shards shards)" >&2
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Benchmark (host sessions)
|
||||
|
||||
# Profiles the host-bound session lifecycle — create → host.launch_runner →
|
||||
# runner boot → first token (`session_cold_start`), plus restart and the
|
||||
# common session actions around it — and renders the journey × metric matrix
|
||||
# into the job summary. Informational: no thresholds, so a noisy shared
|
||||
# runner can't block a PR; regression *gating* stays with benchmark-pr.yml
|
||||
# (store paths) and release.yml (release cuts). The seeded, backend-matrix
|
||||
# trend numbers stay with the nightly benchmark.yml; this workflow's value is
|
||||
# a fresh matrix on the PRs that actually move these numbers.
|
||||
#
|
||||
# Uses dev/benchmarks/omnigent (real server + real `omni host` daemon +
|
||||
# runner against a zero-latency mock LLM — no agent CLI or credentials).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "omnigent/host/**"
|
||||
- "omnigent/runner/**"
|
||||
- "omnigent/server/**"
|
||||
- "dev/benchmarks/**"
|
||||
- ".github/workflows/benchmark-host.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
journeys:
|
||||
description: "Comma-separated journeys (blank = the host-session set)"
|
||||
required: false
|
||||
default: ""
|
||||
iterations:
|
||||
description: "Requests per run (runner journeys stay capped at 5)"
|
||||
required: false
|
||||
default: "100"
|
||||
runs:
|
||||
description: "Timed runs per journey"
|
||||
required: false
|
||||
default: "3"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# No web SPA build during `uv sync` (setup.py _build_web_ui): nothing here
|
||||
# serves the bundle, and the build otherwise times out on public npm.
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
UV_INDEX_URL: https://pypi.org/simple
|
||||
PIP_INDEX_URL: https://pypi.org/simple
|
||||
# The host-session set: the host-bound lifecycle journeys first, then the
|
||||
# common HTTP session actions a user drives around them. Matches the
|
||||
# journey names in dev/benchmarks/omnigent/journeys.py (ALL_JOURNEYS).
|
||||
DEFAULT_JOURNEYS: >-
|
||||
session_cold_start,session_cold_restart,warm_turn,time_to_first_token,interrupt,create_session,fork_session,list_sessions,get_session,load_conversation_history
|
||||
JOURNEYS: ${{ (github.event_name == 'workflow_dispatch' && inputs.journeys) || '' }}
|
||||
ITERATIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.iterations || '100' }}
|
||||
RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || '3' }}
|
||||
|
||||
concurrency:
|
||||
# PR pushes cancel the previous run; manual dispatches never cancel each
|
||||
# other (unique run_id).
|
||||
group: benchmark-host-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
benchmark-host:
|
||||
name: Host session benchmark (sqlite)
|
||||
if: github.repository == 'omnigent-ai/omnigent' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
# Same runtime install as the other benchmark workflows so numbers stay
|
||||
# comparable across them.
|
||||
run: uv sync --extra databricks
|
||||
|
||||
- name: Run host-session benchmark
|
||||
# Throwaway empty SQLite DB (run.py default): these journeys measure
|
||||
# process spin-up and turn latency, not query scale — corpus-scale
|
||||
# numbers live in the nightly benchmark.yml.
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--journeys "${JOURNEYS:-$DEFAULT_JOURNEYS}" \
|
||||
--iterations "$ITERATIONS" \
|
||||
--runs "$RUNS" \
|
||||
--output benchmark-results-host.json
|
||||
|
||||
- name: Render results matrix to job summary
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f benchmark-results-host.json ]]; then
|
||||
uv run --no-sync dev/benchmarks/omnigent/report_markdown.py \
|
||||
--title "Host session benchmark" \
|
||||
benchmark-results-host.json >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: benchmark-results-host-${{ github.run_id }}
|
||||
path: benchmark-results-host.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
@@ -55,7 +55,10 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra databricks
|
||||
# pexpect drives omnigent polly via PTY for the cli_startup journey.
|
||||
run: |
|
||||
uv sync --extra databricks
|
||||
uv pip install pexpect
|
||||
|
||||
# Use the same corpus size as the nightly so baseline numbers are
|
||||
# directly comparable. Cache the seeded DB on the schema head + seed
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
# `databricks` extra carries psycopg[binary] for the Postgres backend.
|
||||
run: uv sync --extra dev --extra databricks
|
||||
run: uv sync --extra databricks
|
||||
|
||||
- name: Install MySQL driver
|
||||
# mysqlclient (mysql+mysqldb://) needs the system client library and is
|
||||
@@ -180,6 +180,10 @@ jobs:
|
||||
--database-uri "${{ steps.db.outputs.uri }}" \
|
||||
--sessions "$SESSIONS" --items-per-session "$ITEMS"
|
||||
|
||||
# pexpect drives omnigent polly via PTY for the cli_startup journey.
|
||||
- name: Install CLI startup dependencies
|
||||
run: uv pip install pexpect
|
||||
|
||||
- name: Run benchmark
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
@@ -189,6 +193,17 @@ jobs:
|
||||
--network-delay-ms "$NETWORK_DELAY_MS" \
|
||||
--output "benchmark-results-${{ matrix.backend }}.json"
|
||||
|
||||
- name: Render results matrix to job summary
|
||||
# The JSON artifact feeds the trend dashboard; this makes the same
|
||||
# numbers readable on the run page without downloading it.
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f "benchmark-results-${{ matrix.backend }}.json" ]]; then
|
||||
uv run --no-sync dev/benchmarks/omnigent/report_markdown.py \
|
||||
--title "Benchmark results" \
|
||||
"benchmark-results-${{ matrix.backend }}.json" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
# matrix.extra (e.g. "databricks") adds an extra for lanes that need it;
|
||||
# empty for the default lanes.
|
||||
run: uv sync --locked --extra all --extra dev ${{ matrix.extra && format('--extra {0}', matrix.extra) || '' }}
|
||||
run: uv sync --locked --extra all --group test ${{ matrix.extra && format('--extra {0}', matrix.extra) || '' }}
|
||||
|
||||
- name: Run pytest
|
||||
shell: bash
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra all --extra dev --extra databricks
|
||||
run: uv sync --locked --extra all --group test --extra databricks
|
||||
- name: Run store + DB tests against PostgreSQL
|
||||
env:
|
||||
OMNIGENT_TEST_DB_URI: postgresql+psycopg://postgres:omnigent@localhost:5432/omnigent_root
|
||||
@@ -314,7 +314,7 @@ jobs:
|
||||
- name: Install system MySQL client library
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -q libmysqlclient-dev
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra all --extra dev --extra databricks && uv pip install mysqlclient
|
||||
run: uv sync --locked --extra all --group test --extra databricks && uv pip install mysqlclient
|
||||
- name: Run store + DB tests against MySQL
|
||||
env:
|
||||
OMNIGENT_TEST_DB_URI: mysql+mysqldb://root:omnigent@127.0.0.1:3306/omnigent_root
|
||||
@@ -389,7 +389,7 @@ jobs:
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Build parity sidecar
|
||||
if: steps.sidecar-cache.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -247,7 +247,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
# Agents classify or edit external-site prose; repository checks never run.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
|
||||
@@ -181,8 +181,8 @@ jobs:
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
- name: Install project + test dependencies
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install bubblewrap + tmux
|
||||
# bubblewrap: the UI tests open terminals under os_env, whose
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
name: Electron Build
|
||||
|
||||
# Manually-triggered build of the Electron desktop shell (web/electron) for
|
||||
# Linux and Windows. Each platform packages on its own native runner —
|
||||
# electron-builder does not reliably cross-compile installers — and uploads the
|
||||
# installers PLUS the electron-updater feed manifests (latest-linux.yml /
|
||||
# latest.yml) as downloadable workflow artifacts. Unsigned: no signing creds are
|
||||
# wired here, so `CSC_IDENTITY_AUTO_DISCOVERY=false` forces an unsigned build
|
||||
# rather than failing when a cert is absent. No publishing to a provider / no
|
||||
# release upload (`--publish never`): the artifacts are captured here for manual
|
||||
# placement onto the omnigent.ai update feed (omnigent-site repo + artifact host).
|
||||
#
|
||||
# Run it from the Actions tab (Run workflow). macOS is intentionally omitted —
|
||||
# its signed/notarized build lives elsewhere.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch, tag, or SHA to build."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# One build per ref: back-to-back manual dispatches on the same ref queue
|
||||
# instead of running concurrently (keyed on ref only — including run_id would
|
||||
# make every run its own group, defeating the serialization).
|
||||
group: electron-build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
# Keep building the other platform even if one fails, so a Windows-only
|
||||
# break still yields the Linux installers (and vice versa).
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
build-script: build:linux
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
build-script: build:win
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: ./.github/actions/setup-pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |-
|
||||
pnpm install --frozen-lockfile --filter @omnigent/electron
|
||||
pnpm install --frozen-lockfile --filter web
|
||||
|
||||
- name: Build ${{ matrix.platform }} app
|
||||
working-directory: web/electron
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
# No signing credentials in CI: force an unsigned build instead of
|
||||
# letting electron-builder fail hunting for a certificate.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
# electron-builder downloads Electron/tooling from GitHub; the token
|
||||
# lifts the anonymous rate limit that otherwise flakes downloads.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: pnpm run ${{ matrix.build-script }} -- --publish never
|
||||
|
||||
# One artifact per platform bundling the COMPLETE electron-updater feed —
|
||||
# the installer(s), the .blockmap electron-updater needs for differential
|
||||
# downloads (referenced by path inside latest*.yml; the .deb has no
|
||||
# blockmap since debs aren't differentially updated), and the feed
|
||||
# manifest (latest-linux.yml / latest.yml). upload-artifact zips all
|
||||
# matched files into a single download, so each platform yields one zip
|
||||
# whose contents can be dropped straight onto a feed root (local HTTP
|
||||
# server for testing, or public/_desktop/updates/ on the artifact host).
|
||||
# Ship only the distributables + feed files, not electron-builder's
|
||||
# unpacked intermediates (dist/*-unpacked).
|
||||
#
|
||||
# electron-builder writes the latest*.yml manifests to dist/ even under
|
||||
# --publish never (a publish config exists in build.*.publish, so
|
||||
# update-info generation runs; --publish only skips the provider upload).
|
||||
# The manifest lists each artifact with sha512 + size + relative url.
|
||||
|
||||
- name: Upload Linux feed
|
||||
if: matrix.platform == 'linux'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: omnigent-desktop-linux
|
||||
path: |
|
||||
web/electron/dist/*.AppImage
|
||||
web/electron/dist/*.AppImage.blockmap
|
||||
web/electron/dist/*.deb
|
||||
web/electron/dist/latest-linux.yml
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload Windows feed
|
||||
if: matrix.platform == 'win'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: omnigent-desktop-win
|
||||
path: |
|
||||
web/electron/dist/*.exe
|
||||
web/electron/dist/*.exe.blockmap
|
||||
web/electron/dist/latest.yml
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -233,10 +233,10 @@ jobs:
|
||||
# PAT passthrough for the codex / claude-sdk auth commands.
|
||||
echo "DATABRICKS_BEARER=$LLM_API_KEY" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install project and dev dependencies
|
||||
- name: Install project and test dependencies
|
||||
# Matches e2e.yml; ``--extra all`` pulls the harness SDKs so the
|
||||
# executor adapters import at collection time.
|
||||
run: uv sync --extra all --extra dev
|
||||
run: uv sync --extra all --group test
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: ./.github/actions/setup-pnpm
|
||||
|
||||
@@ -229,8 +229,8 @@ jobs:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
- name: Install project + test dependencies
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install bubblewrap + tmux
|
||||
# bubblewrap: the UI tests open terminals under os_env, whose
|
||||
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
# Matches ci.yml; ``--extra all`` pulls the harness SDKs so
|
||||
# executor adapters import at collection time.
|
||||
run: uv sync --extra all --extra dev
|
||||
run: uv sync --extra all --group test
|
||||
|
||||
- name: Run pytest target
|
||||
# Inputs validated by prep. Word-splitting on $TEST_TARGET /
|
||||
|
||||
@@ -25,8 +25,10 @@ name: Issue Triage
|
||||
# 5. Flags incomplete issues — `needs-info` (replaces priority label)
|
||||
# 6. Optionally comments when a duplicate or related issue is found
|
||||
# (disabled by default; never comments when nothing matches)
|
||||
# 7. Optionally closes validated high-confidence duplicates (disabled by default)
|
||||
# 8. Assigns P0/P1 issues to a maintainer via round-robin
|
||||
# 7. Assigns an owner to every triaged open issue via round-robin — duplicates
|
||||
# included, so the owner persists if the issue is later reopened
|
||||
# 8. Optionally closes validated high-confidence duplicates (disabled by
|
||||
# default), after the owner above is assigned
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -233,7 +235,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
# The tools-less triage agent emits JSON; no repository checks run.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
@@ -713,40 +716,25 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
duplicate_decision=$(jq -r '.duplicate_decision' /tmp/triage_result.json)
|
||||
if [ "$duplicate_decision" = "duplicate" ]; then
|
||||
close_duplicate_issue=$(jq -r '.close_duplicate_issue' /tmp/triage_result.json)
|
||||
if [ "$close_duplicate_issue" = "true" ]; then
|
||||
duplicate_of=$(jq -r '.duplicate_of' /tmp/triage_result.json)
|
||||
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--json state --jq '.state')
|
||||
if [ "$issue_state" = "OPEN" ]; then
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--duplicate-of "$duplicate_of"
|
||||
fi
|
||||
else
|
||||
echo "Duplicate closure disabled; leaving issue open."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
# Assign an owner to every triaged open issue, BEFORE the duplicate
|
||||
# closure below. Duplicates get an owner too, and it persists if the
|
||||
# issue is later reopened — triage only fires on `opened`, so a
|
||||
# reopened issue would otherwise come back unassigned.
|
||||
|
||||
# If the issue was filed by a maintainer, assign it to them directly.
|
||||
author=$(jq -r '.author.login // empty' /tmp/issue.json)
|
||||
maintainer_assigned=false
|
||||
if [ -n "$author" ] && grep -qxF "$author" .github/MAINTAINER; then
|
||||
echo "Issue filed by maintainer $author — assigning to author"
|
||||
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--json state --jq '.state')
|
||||
if [ "$issue_state" = "OPEN" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$author"
|
||||
maintainer_assigned=true
|
||||
fi
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$author"
|
||||
maintainer_assigned=true
|
||||
fi
|
||||
|
||||
# Otherwise, assign an owner: the least-loaded area owner, with LLM
|
||||
# rank as a tiebreaker (load primary, rank secondary). Symmetric with
|
||||
# the PR reviewer path. Skipped if the maintainer-author was already
|
||||
# assigned above. Every triaged issue gets an owner — the only issues
|
||||
# assigned above. Every triaged issue gets an owner — duplicates
|
||||
# included, even ones about to be closed below — so the only issues
|
||||
# left unassigned are needs_info ones (too vague to route until the
|
||||
# reporter adds detail).
|
||||
needs_info=$(jq -r '.needs_info // false' /tmp/triage_result.json)
|
||||
@@ -793,11 +781,26 @@ jobs:
|
||||
|
||||
assignee=$(cat /tmp/assignee.txt)
|
||||
if [ -n "$assignee" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$assignee"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Finally, close the issue if it is a high-confidence duplicate and
|
||||
# closure is enabled. The owner assigned above stays on the issue,
|
||||
# ready if it is reopened.
|
||||
duplicate_decision=$(jq -r '.duplicate_decision' /tmp/triage_result.json)
|
||||
if [ "$duplicate_decision" = "duplicate" ]; then
|
||||
close_duplicate_issue=$(jq -r '.close_duplicate_issue' /tmp/triage_result.json)
|
||||
if [ "$close_duplicate_issue" = "true" ]; then
|
||||
duplicate_of=$(jq -r '.duplicate_of' /tmp/triage_result.json)
|
||||
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--json state --jq '.state')
|
||||
if [ "$issue_state" = "OPEN" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$assignee"
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--duplicate-of "$duplicate_of"
|
||||
fi
|
||||
else
|
||||
echo "Duplicate closure disabled; leaving issue open."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Kustomize validate
|
||||
|
||||
# Renders every deploy/kubernetes overlay with `kustomize build` so manifest
|
||||
# drift (duplicate bases, missing patches, invalid YAML) is caught in CI
|
||||
# rather than at deploy time. Only runs when overlay or base files change.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'deploy/kubernetes/**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'release/v[0-9]*'
|
||||
paths:
|
||||
- 'deploy/kubernetes/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: kustomize-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Kustomize build (overlays)
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install kustomize
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
sudo mv kustomize /usr/local/bin/
|
||||
|
||||
- name: Render all overlays
|
||||
run: |
|
||||
failed=0
|
||||
for overlay in deploy/kubernetes/overlays/*/; do
|
||||
name="$(basename "$overlay")"
|
||||
echo "::group::$name"
|
||||
if kustomize build "$overlay"; then
|
||||
echo "::endgroup::"
|
||||
else
|
||||
echo "::endgroup::"
|
||||
echo "::error::kustomize build failed for overlay '$name'"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
@@ -75,7 +75,15 @@ jobs:
|
||||
# `--locked` is the hard gate: fails if uv.lock is out of sync with
|
||||
# pyproject.toml (a bare `uv run pre-commit` would re-lock first and mask
|
||||
# a stale lockfile). Fix locally with `uv lock`.
|
||||
run: uv sync --locked --extra dev
|
||||
# Pyrefly checks optional integrations against their real packages.
|
||||
# Compose capability extras with lint tooling instead of duplicating
|
||||
# runtime dependencies in the repository-only lint group.
|
||||
run: |
|
||||
uv sync --locked --group lint \
|
||||
--extra hindsight \
|
||||
--extra nimble \
|
||||
--extra s3 \
|
||||
--extra tracing
|
||||
|
||||
# Sets up Node 20 + pnpm, with pnpm dependency caching keyed on the
|
||||
# workspace lockfile. pnpm is pinned in .github/actions/setup-pnpm.
|
||||
|
||||
@@ -164,7 +164,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
# Reviews a prefetched diff against trusted main; it does not run PR checks.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
run: uv sync
|
||||
|
||||
- name: Find previous stable release tag
|
||||
id: prev
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
if: steps.prev.outputs.found == 'true'
|
||||
run: |
|
||||
git checkout "${{ steps.prev.outputs.tag }}"
|
||||
uv sync --extra dev
|
||||
uv sync
|
||||
uv run --no-sync dev/benchmarks/omnigent/seed.py \
|
||||
--database-uri "sqlite:///bench.db" \
|
||||
--sessions 5000 --items-per-session 200
|
||||
@@ -362,7 +362,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
run: uv sync
|
||||
|
||||
- name: Run baseline benchmark
|
||||
run: |
|
||||
@@ -413,7 +413,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
run: uv sync
|
||||
|
||||
# The seeded bench.db is at the previous release's schema head. The
|
||||
# candidate (newer code) auto-migrates on server boot, but the z7
|
||||
@@ -473,7 +473,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
run: uv sync
|
||||
|
||||
- name: Download results
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -188,7 +188,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
# The tools-less triage agent emits JSON; no repository checks run.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
|
||||
@@ -30,6 +30,23 @@ on:
|
||||
schedule:
|
||||
# Every 12 hours (00:00 and 12:00 UTC).
|
||||
- cron: "0 */12 * * *"
|
||||
# Fast smoke on PRs that touch the server↔runner contract surface.
|
||||
# Runs both Config 1 (new server, old runner) and Config 2 (new runner,
|
||||
# old server) against the latest stable release so protocol regressions
|
||||
# surface before merge, not in the overnight full matrix.
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "omnigent/runner/**"
|
||||
- "omnigent/server/**"
|
||||
- "omnigent/host/**"
|
||||
- "web/src/**"
|
||||
- "tests/e2e/**"
|
||||
- "tests/e2e_ui/**"
|
||||
- "tests/_helpers/compat.py"
|
||||
- ".github/actions/compat-smoke-run/**"
|
||||
- ".github/actions/compat-smoke-ui-run/**"
|
||||
- ".github/workflows/server-compat.yml"
|
||||
|
||||
concurrency:
|
||||
group: backcompat-${{ github.workflow }}-${{ github.sha }}
|
||||
@@ -39,11 +56,118 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Compute the full pairwise (server, runner) matrices. Integration is the
|
||||
# single openai-agents leg (claude-sdk/codex reject the mock LLM's
|
||||
# "mock-model"); e2e is sharded per cell. See backcompat-pairwise-matrix.sh.
|
||||
# ── Fast smoke: both compat configs against the latest stable release ──────
|
||||
# Runs on every PR that touches the server↔runner contract surface (paths
|
||||
# filter above), plus every schedule/dispatch run. Resolves the latest
|
||||
# final (non-prerelease) tag once, then fans out to Config 1 and Config 2.
|
||||
# The full pairwise matrix (backcompat-e2e / backcompat-integration) still
|
||||
# runs on schedule/dispatch and covers the broader version history.
|
||||
resolve-latest:
|
||||
name: Resolve latest stable tag
|
||||
# PRs: always. Schedule/dispatch: always.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
latest_tag: ${{ steps.tag.outputs.latest_tag }}
|
||||
steps:
|
||||
- name: Checkout (tags only)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
sparse-checkout: .github/scripts/ci
|
||||
persist-credentials: false
|
||||
- name: Resolve latest final tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
# Latest final (non-prerelease) tag by version sort.
|
||||
tag=$(git tag --sort=-v:refname \
|
||||
| grep -viE '(^|[^a-z])(rc|dev|pre)[0-9]' \
|
||||
| head -1)
|
||||
if [ -z "$tag" ]; then
|
||||
echo "No stable release tag found" >&2; exit 1
|
||||
fi
|
||||
echo "latest_tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved latest stable tag: $tag" >&2
|
||||
|
||||
compat-smoke-config1:
|
||||
name: "Compat smoke – Config 1 (new server / old runner ${{ needs.resolve-latest.outputs.latest_tag }})"
|
||||
needs: resolve-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run compat smoke (Config 1)
|
||||
uses: ./.github/actions/compat-smoke-run
|
||||
with:
|
||||
runner_version: ${{ needs.resolve-latest.outputs.latest_tag }}
|
||||
artifact_suffix: "-config1"
|
||||
|
||||
compat-smoke-config2:
|
||||
name: "Compat smoke – Config 2 (new runner / old server ${{ needs.resolve-latest.outputs.latest_tag }})"
|
||||
needs: resolve-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run compat smoke (Config 2)
|
||||
uses: ./.github/actions/compat-smoke-run
|
||||
with:
|
||||
server_version: ${{ needs.resolve-latest.outputs.latest_tag }}
|
||||
artifact_suffix: "-config2"
|
||||
|
||||
compat-smoke-ui-config-a:
|
||||
name: "Compat smoke – UI Config A (new SPA / old server ${{ needs.resolve-latest.outputs.latest_tag }})"
|
||||
needs: resolve-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run UI compat smoke (Config A)
|
||||
uses: ./.github/actions/compat-smoke-ui-run
|
||||
with:
|
||||
server_version: ${{ needs.resolve-latest.outputs.latest_tag }}
|
||||
artifact_suffix: "-ui-config-a"
|
||||
|
||||
compat-smoke-ui-config-b:
|
||||
name: "Compat smoke – UI Config B (old SPA ${{ needs.resolve-latest.outputs.latest_tag }} / new server)"
|
||||
needs: resolve-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run UI compat smoke (Config B)
|
||||
uses: ./.github/actions/compat-smoke-ui-run
|
||||
with:
|
||||
ui_version: ${{ needs.resolve-latest.outputs.latest_tag }}
|
||||
artifact_suffix: "-ui-config-b"
|
||||
|
||||
# ── Full pairwise matrix (schedule + dispatch only) ────────────────────────
|
||||
# Compute the full pairwise (server, runner) and UI matrices.
|
||||
# Integration is the single openai-agents leg (claude-sdk/codex reject the
|
||||
# mock LLM's "mock-model"); e2e is sharded per cell.
|
||||
# UI matrix is server-only (runner is always main; the SPA is always HEAD).
|
||||
setup:
|
||||
name: setup
|
||||
# The full pairwise matrix is expensive — skip it on PR triggers (the
|
||||
# fast smoke jobs above cover the PR case).
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -132,3 +256,50 @@ jobs:
|
||||
harness: ${{ matrix.harness }}
|
||||
model: ${{ matrix.model }}
|
||||
workers: ${{ matrix.workers }}
|
||||
|
||||
# tests/e2e_ui for every old server tag × shard (server axis only).
|
||||
setup-ui:
|
||||
name: setup-ui
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
ui_matrix: ${{ steps.matrix.outputs.ui_matrix }}
|
||||
steps:
|
||||
- name: Check out CI scripts + tags
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: .github/scripts/ci
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Compute UI matrix
|
||||
id: matrix
|
||||
env:
|
||||
VERSIONS: ${{ github.event.inputs.versions }}
|
||||
NUM_SHARDS: "3"
|
||||
run: bash .github/scripts/ci/backcompat-ui-matrix.sh
|
||||
|
||||
backcompat-e2e-ui:
|
||||
name: "Backcompat e2e-ui (Config ${{ matrix.config }}: ${{ matrix.config == 'A' && matrix.server || matrix.ui }}, shard ${{ matrix.shard_id }}/${{ matrix.num_shards }})"
|
||||
needs: setup-ui
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 6
|
||||
matrix: ${{ fromJSON(needs.setup-ui.outputs.ui_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run e2e-ui suite for this cell
|
||||
uses: ./.github/actions/compat-smoke-ui-run
|
||||
with:
|
||||
server_version: ${{ matrix.server }}
|
||||
ui_version: ${{ matrix.ui }}
|
||||
full_suite: "true"
|
||||
shard_id: ${{ matrix.shard_id }}
|
||||
num_shards: ${{ matrix.num_shards }}
|
||||
artifact_suffix: "-config${{ matrix.config }}-${{ matrix.config == 'A' && matrix.server || matrix.ui }}-shard${{ matrix.shard_id }}"
|
||||
|
||||
@@ -106,8 +106,8 @@ jobs:
|
||||
# the container's system Python, not the host interpreter).
|
||||
key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --extra all --extra dev
|
||||
- name: Install project + test dependencies
|
||||
run: uv sync --extra all --group test
|
||||
|
||||
# No "playwright install": the pinned image ships matching Chromium + deps
|
||||
# under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright).
|
||||
|
||||
@@ -157,8 +157,8 @@ jobs:
|
||||
# must not share a key or a cross-restore would mismatch.
|
||||
key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --extra all --extra dev
|
||||
- name: Install project + test dependencies
|
||||
run: uv sync --extra all --group test
|
||||
|
||||
# No "playwright install": the pinned image already ships matching Chromium
|
||||
# + system deps under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright), so the
|
||||
|
||||
@@ -50,7 +50,8 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra dev
|
||||
# tests/inner includes tracing tests that import the OpenTelemetry SDK.
|
||||
run: uv sync --locked --group test --extra tracing
|
||||
|
||||
- name: Import + CLI smoke
|
||||
run: |
|
||||
|
||||
@@ -142,7 +142,7 @@ repos:
|
||||
|
||||
# Fail if routing.proto changed without regenerating the committed
|
||||
# bindings (or vice versa). Verify-only, not a fixer: regen needs
|
||||
# grpcio-tools, so CI's `uv sync --extra dev` enforces it (like ktlint).
|
||||
# grpcio-tools, so CI's `uv sync --group lint` enforces it (like ktlint).
|
||||
- id: routing-pb2-fresh
|
||||
name: routing protobuf bindings are up to date
|
||||
language: system
|
||||
|
||||
+177
@@ -5,6 +5,183 @@ generated at release time from each PR's `## Changelog` section, tagged by the
|
||||
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
|
||||
website under `/releases`.
|
||||
|
||||
## [v0.10.0] — 2026-08-19
|
||||
|
||||
- [Bug fix / Test/CI] Host daemons now honor standard proxy environment variables without forwarding (#1029)
|
||||
- [UI / Feature] Route host- and session-scoped server requests to the replica holding the host's tunnel, and signal `wrong_replica` (HTTP 400 / WS 4400) so clients re-address on a miss. (#2037)
|
||||
- [Bug fix] Keep `serve-mcp` responsive to pings and additional requests during slow tool calls. (#2813)
|
||||
- [UI / Feature / Docs / Test/CI] White-label the web UI from server config with custom names, headings, safe logo assets, favicon, and optional Omnigent attribution. (#2857)
|
||||
- [Bug fix] A transient server hiccup no longer pins a session to the wrong working directory for the rest of the conversation. (#3017)
|
||||
- [Bug fix] Claude SDK agents now discover large MCP tool definitions on demand instead of loading every schema up front. (#3134)
|
||||
- [Bug fix / Docs] Sub-agents no longer inherit their parent's bundle directory (skills, local tools); resolvable children use their own, while unresolvable children never fall back to the parent's (#3567)
|
||||
- [UI / Bug fix] Native-harness sessions no longer leave a stale duplicate of your message (or of the assistant's reply) pinned to the bottom of the web transcript. (#3595)
|
||||
- [Feature] GitHub policy blocks tag pushes (`--tags` / `--follow-tags` / `refs/tags/` refspecs) by default; opt out with `deny_tag_push: false`. (#3620)
|
||||
- [Bug fix] Server URLs and log paths in `omni host status` are now proper clickable links instead of text the terminal has to guess at (#3862)
|
||||
- [UI / Bug fix / Feature] agy sessions now mirror tool calls and sub-agents into the web UI, and no longer duplicate or truncate replies (#3890)
|
||||
- [Bug fix] `force_sandbox` (and any declared `os_env.sandbox`) now actually applies to a Claude Code native session's file/shell tools, not just the terminal process (#3910)
|
||||
- [Bug fix] Native sessions no longer fail with "terminal failed to start" when the host daemon was launched from a directory that has since been deleted (#3974)
|
||||
- [UI / Feature] The server can offer several sandbox providers at once (`sandbox.providers`), and the new-session picker lists one option per provider (#4006)
|
||||
- [Bug fix / Feature / Chore] Switching between conversations is instant — background conversations stay connected, so returning to one shows messages that arrived while you were away (#4113)
|
||||
- [Bug fix] `omnigent pi` now uses each model's real context window and output limit, instead of compacting early and truncating long replies on high-context models. (#4178)
|
||||
- [Feature] Route a host's tunnel, its runners, and its session traffic to one replica so multi-replica deployments keep host-scoped requests sticky. (#4185)
|
||||
- [Bug fix] A custom agent that declares its own provider auth now launches on codex-native instead of stalling on the Codex sign-in screen. (#4208)
|
||||
- [Bug fix] `omnigent server --host 0.0.0.0` with `OMNIGENT_LOCAL_SINGLE_USER=1` no longer 401s every request and 403s the host tunnel; the single-user marker is honored on network-exposed binds, with a warning that the server serves unauthenticated requests (#4224)
|
||||
- [UI / Bug fix] New chats in a project with a default base branch now fork a fresh branch off that default instead of reopening your last-used worktree (#4229)
|
||||
- [UI] New-chat landing header uses tighter Otto sizing and responsive headline scale (#4233)
|
||||
- [Bug fix] Changing effort or model mid-conversation on a Claude Code session no longer risks wedging the terminal or silently keeping the old setting. (#4250)
|
||||
- [Bug fix] `omnigent chat <remote-url>` can now start a new conversation with an agent registered on the remote server (#4260)
|
||||
- [UI / Bug fix] Collapsed "Worked for …" rows in a chat transcript now sit at an even spacing and draw their full-width divider (#4284)
|
||||
- [UI / Bug fix] Voice dictation now inserts text at your cursor instead of appending it to the end of the composer (#4290)
|
||||
- [UI / Feature] The file panel can now browse anywhere the session can reach, not just the folder it started in (#4306)
|
||||
- [UI / Bug fix] Navigating to another session while a new one is still being created no longer yanks you into the new session when it finishes (#4307)
|
||||
- [UI / Bug fix] New polly and debby sessions now show the same "Starting up…" spinner as claude-code, instead of a "Connecting…" row under the composer (#4312)
|
||||
- [Bug fix] Duplicate-detection comments no longer ask you to close your issue when the matching issue is already closed — an already-fixed match now asks whether you're on a build with the fix, and treats a still-reproducing report as a regression. (#4313)
|
||||
- [Bug fix] Kimi and Hermes sessions launch again — their version checks compared against the wrong version series and rejected every current CLI. (#4314)
|
||||
- [UI / Feature] Organizations can preconfigure Omnigent server URLs through Android managed configuration, and they show up ready to tap in the app's server list. (#4315)
|
||||
- [Feature] `omnigent host --background` starts the local server and registers this machine as a host without tying up a terminal. (#4317)
|
||||
- [UI / Breaking] Reverts the shared-session approval-authority and message-attribution features (#2150 stack); session approvals are again available to any shared editor. (#4318)
|
||||
- [UI] Administrators can preset the iOS app's server URLs with a managed app configuration, so managed users pick their organization's server instead of typing it (#4319)
|
||||
- [Bug fix] Fixed a bug where an archived session (or an archived sub-agent) could be gated as if it had spent nothing, letting a tool call proceed over its actual budget. (#4320)
|
||||
- [Feature] `omnigent start` starts the local server and registers this machine as a host — the on switch to go with `omnigent stop`. (#4321)
|
||||
- [Bug fix / Test/CI] Fix `omnidev` restart loop on Linux caused by non-mutating file access events (#4330)
|
||||
- [Feature / Docs] Issue triage now explains its impact assessment and priority in one bot comment instead of adding severity labels. (#4334)
|
||||
- [UI / Bug fix] Opening the left sidebar no longer squeezes the chat below its minimum width — the browser/workspace panel yields instead and restores its width when the sidebar collapses. (#4337)
|
||||
- [Bug fix] Pi sessions on a Databricks workspace no longer hang when the workspace model list is unavailable — non-Claude models are routed by family, and a model that truly can't be served now says so instead of never replying (#4339)
|
||||
- [Chore] Files in the viewer load faster — workspace-file reads are now gzipped, cutting a 1 MB text file from ~2.3 s to ~1.3 s (#4341)
|
||||
- [Bug fix / Chore] A claude-native session no longer shows "Working…" forever when Claude exits (#4344)
|
||||
- [UI] The sidebar "Needs response" badge now uses the brand accent color (pink by default), matching the unread indicator. (#4346)
|
||||
- [UI] Refreshed modal styling — larger rounded corners, softer drop shadow, and roomier padding (#4347)
|
||||
- [Bug fix] Pi sessions on a proxy that exposes both Anthropic and OpenAI surfaces now send each model to the surface its family speaks, instead of routing everything through Anthropic and hanging (#4348)
|
||||
- [Bug fix] Session snapshots no longer fail when a session contains a malformed legacy agent id. (#4350)
|
||||
- [Bug fix] A session whose message the runner refuses now reports the failure and its reason instead of appearing to finish successfully. (#4354)
|
||||
- [UI / Feature] Hover the collapsed sidebar toggle to peek the conversation list without pinning it open. (#4355)
|
||||
- [Bug fix] Generic-ACP / Goose / Qwen turns that fail now report the exception type instead of a blank "inner executor error: " with no detail. (#4362)
|
||||
- [UI / Bug fix] Messages sent from the chat UI no longer stop reaching the agent after a dropped network request (#4366)
|
||||
- [UI / Chore] The Files panel is now split into separate **Files** (folder tree) and **Changes** (changed files) tabs (#4367)
|
||||
- [UI / Feature] Chat messages now show their timestamp beside the Copy/Fork actions. (#4372)
|
||||
- [Bug fix] A conversation link copied from your browser now works wherever a server URL is expected, instead of failing later with an opaque "Method Not Allowed" crash (#4374)
|
||||
- [UI / Bug fix / Test/CI] Clicking a sidebar session that needs a response no longer runs its title under the "Needs response" tag, and the Inbox count badge now matches the sidebar's pink accent (#4375)
|
||||
- [UI / Bug fix] Maximized workspace panel no longer shows chat content through a transparent background in dark mode. (#4376)
|
||||
- [Bug fix] Agents now inherit your ssh-agent, so git-over-SSH and SSH-cert-authenticated tooling work in agent shells and terminals (#4377)
|
||||
- [Bug fix] The sidebar remembers your session filter across reloads instead of resetting to "All sessions" (#4381)
|
||||
- [Feature / Docs / Test/CI] Blaxel is now available as a sandbox provider for CLI and managed-host deployments. (#4383)
|
||||
- [UI] The Chat/Terminal switcher is now a segmented toggle — both views are visible at a glance and switching takes one click (#4385)
|
||||
- [Bug fix / Feature] `omnigent run --server local` runs against a local server, overriding any configured server default — and the no-AGENT `omnigent run --server ""` no longer fails with `Agent path not found: https:` (#4387)
|
||||
- [UI / Bug fix] Terminal-first sessions return to chat automatically when the runner stops or disconnects, instead of stranding on an empty "No terminals available" terminal view (#4388)
|
||||
- [Bug fix] The `build-omnigent` skill is available again in native `omnigent claude` and `omnigent codex` sessions (#4391)
|
||||
- [Bug fix] A custom ACP agent can declare the environment variables it authenticates with via `env_passthrough`, and a stalled ACP handshake now reports which call timed out instead of failing with an empty message. (#4392)
|
||||
- [Bug fix / Feature] The Copilot harness now authenticates with your existing `gh auth login` session, and a GitHub Enterprise host can be set via `omnigent setup` (#4396)
|
||||
- [Bug fix] MCP server configs can now use `${VAR}` placeholders in the `url` field, not just in `headers` — so a config can be committed to version control without hardcoding the endpoint. (#4398)
|
||||
- [Bug fix] `kimi-native` sub-agents honor `executor.config.yolo: true` (launching `kimi --yolo`) and `antigravity-native` sub-agents honor `permission_mode: bypassPermissions`, so server-spawned workers no longer stall on interactive approval prompts. (#4401)
|
||||
- [Bug fix] Resuming a claude-native session no longer drops a message sent right after the session starts. (#4403)
|
||||
- [Bug fix] A sub-agent session no longer logs a spurious "did not resolve in the parent spec" warning on every turn. (#4435)
|
||||
- [UI / Bug fix] Bulk-select sessions and move them to a project in one action via the new folder icon in the selection bar. (#4452)
|
||||
- [UI] Codex's bypass-approvals option now matches Claude's clean permission UX — no more red warning banners (#4467)
|
||||
- [Bug fix] Compaction snapshots no longer store raw image data, which cuts the size of newly written compacted conversation rows substantially. (#4470)
|
||||
- [UI / Bug fix] The new-session workspace picker now navigates to `~/…` paths and shows a clear error when a typed path doesn't exist (#4480)
|
||||
- [UI / Feature] Harness launch failures now show a clear title, cause, and suggested fix instead of a raw error code and truncated log tail. (#4485)
|
||||
- [UI / Feature] Sub-agents are now auto-assigned readable structured names (e.g. `researcher-1`) and a task-derived display label in the Agents panel (#4489)
|
||||
- [UI] Restored the down chevrons on the new-session composer's chips and made every dropdown trigger show a pointer cursor (#4493)
|
||||
- [UI / Bug fix] Modal dialogs and the workspace "Open new" menu no longer render behind the embedded browser pane (#4500)
|
||||
- [Bug fix] Named `sys_session_send` no longer returns a spurious 404 on the second and later sub-agent sends from a bundled agent (#4501)
|
||||
- [Bug fix] Session search no longer hangs on "Searching…" — content search is now backed by a trigram index and bounded by a timeout. (#4502)
|
||||
- [Bug fix / Test/CI] Fixes an HTTP client resource leak when OpenAI Agents SDK executors shut down. (#4508)
|
||||
- [Bug fix / Test/CI] Honor OmnigentClient timeout for ordinary Python SDK HTTP requests. (#4509)
|
||||
- [Bug fix] Sessions ride out brief runner-tunnel drops (laptop sleep-wake, ingress recycles) without flashing Failed or truncating the streaming reply. (#4516)
|
||||
- [Bug fix] `omnigent` no longer crashes on startup when your shell sets a SOCKS proxy (e.g. `ALL_PROXY=socks5://…`) and the `httpx[socks]` extra isn't installed (#4517)
|
||||
- [UI / Bug fix] Attaching an unsupported file in a new chat now tells you why up front and keeps your message instead of losing it (#4519)
|
||||
- [Bug fix] `omni` now works on machines with an HTTP proxy configured, and reports an unreachable server as a clear error instead of a crash. (#4520)
|
||||
- [Bug fix] Sessions that outlive their 60-minute runner bearer no longer permanently lose runner→server auth when the token re-mint is rejected — the runner now falls back to the machine's SDK/OIDC credential. (#4521)
|
||||
- [Bug fix] Harness logs now go to a file under `~/.omnigent/logs/harness/` (or the runner's log), and a failing ACP turn quotes the agent's own error output instead of dropping it. (#4523)
|
||||
- [Bug fix] An `openai-agents` agent with no pinned model no longer fails with a confusing "install databricks-sdk" error when only OpenAI credentials are missing (#4526)
|
||||
- [Bug fix] Claude native sessions now report per-turn token usage (`gen_ai.usage.input_tokens` / `output_tokens`) to MLflow and other OpenTelemetry backends. (#4530)
|
||||
- [UI / Bug fix / Feature] Move a running session to another machine from the host badge in the composer. (#4531)
|
||||
- [Bug fix] Upgrading omnigent in place no longer breaks harness launches on already-running runners (#4539)
|
||||
- [Chore] The session-search index migration builds its Postgres indexes concurrently, so upgrades no longer block writes while the indexes build. (#4541)
|
||||
- [Feature] The Android app now opens Databricks workspaces on their `/omnigent` app instead of the workspace landing page. (#4543)
|
||||
- [Bug fix] `omnigent host` pointed at a local server that has exited now stops after ~5 minutes with a clear error instead of reconnecting forever. (#4544)
|
||||
- [Bug fix] Runners no longer crash-loop at session start when signal-handler registration fails; they log one warning and keep working. (#4545)
|
||||
- [Bug fix] Session search now returns results instead of timing out on large workspaces. (#4546)
|
||||
- [UI / Bug fix] Modal buttons like Stop session and Clone now show a spinner while the action is running, instead of just greying out. (#4548)
|
||||
- [UI / Bug fix] The desktop server picker moved from the window title bar to the bottom of the sidebar, fixing an overlap with the chat header on narrow windows — and Windows and Linux desktop now have it too (#4551)
|
||||
- [UI] The embedded terminal connects in the background and stays connected across Chat/Terminal flips and recent-session switches, so opening it is near-instant instead of reconnecting every time. (#4552)
|
||||
- [Bug fix] The Android app no longer shows the Databricks workspace navigation bar around Omnigent when connecting to a workspace-hosted server. (#4555)
|
||||
- [UI / Feature] On the macOS desktop app, the sidebar header now shares the title-bar row with the window controls — the empty strip above the sidebar and the redundant wordmark row are gone, and the Collapse/Search/Settings buttons sit beside the traffic lights. (#4557)
|
||||
- [Bug fix] Codex sessions on a ChatGPT-account or API-key login launch again, instead of failing with "model is not supported when using Codex with a ChatGPT account" (#4558)
|
||||
- [UI] Connecting the iOS app to a Databricks workspace now opens Omnigent directly and hides the workspace navigation bar (#4559)
|
||||
- [Bug fix] kiro sessions no longer fail the first message with a connection error when the kiro TUI is slow to start (#4562)
|
||||
- [Bug fix] `omnigent host` now warns once and backs off when a server accepts connections but never responds, waits out (up to 120s) a slow-booting local server instead of stranding it — stopping it if it truly fails — recovers fast runner starts after a zygote crash, and no longer leaves empty log files behind. (#4563)
|
||||
- [Bug fix] Named `sys_session_send` no longer returns a spurious 404 on the second and later sub-agent sends from a bundled agent (#4564)
|
||||
- [UI / Bug fix] Deleting a session removes it from the sidebar immediately instead of waiting for the server to finish tearing it down (#4566)
|
||||
- [Bug fix] Session search no longer times out on large workspaces. (#4567)
|
||||
- [UI / Bug fix] Fixed iOS controls rendering under the status bar on Databricks workspace-hosted servers (#4568)
|
||||
- [UI / Feature] You can now leave a session someone shared with you — pick "Leave session" from its sidebar row menu to clear it from your sidebar without asking the owner (#4571)
|
||||
- [UI / Bug fix] The workspace Files panel now shows hidden files by default, and its eye icon shows whether they are visible rather than what clicking will do. (#4575)
|
||||
- [Bug fix] Switching a session's agent now updates the tools its native harness can call, instead of leaving the previous agent's tools in place (#4576)
|
||||
- [Bug fix] The native pane reaper no longer kills a terminal that is actively producing output when the harness status pipeline stalls; it now checks tmux's own activity clock before reaping. (#4577)
|
||||
- [Bug fix] A silently stalled claude-native transcript forwarder now self-recovers within five minutes and logs exactly where it stalled, instead of freezing mirroring and session status indefinitely. (#4578)
|
||||
- [Bug fix] A claude-native transcript forwarder that stops — cancelled, crashed, or returned — now always logs an attributed exit line instead of dying silently. (#4579)
|
||||
- [Bug fix] A stray hook event from another Claude session can no longer silently redirect a claude-native session's transcript mirroring; session identity now changes only via SessionStart announcements. (#4580)
|
||||
- [Bug fix] `omni claude` streaming, statusline, and typing during tool-running turns now respond at native speed: hook subprocesses skip the framework's eager import graph, and the blocking hook path runs as shell + a loopback `curl` relayed by the long-lived runner instead of spawning a Python interpreter per event. (#4582)
|
||||
- [UI / Bug fix / Feature] Fork a sub-agent to promote it into a top-level session of its own. (#4584)
|
||||
- [Bug fix] Upgrading omnigent in place no longer breaks runner launches on already-running hosts (#4587)
|
||||
- [UI / Bug fix] Native-harness sessions no longer briefly drop your in-flight message bubble when an interrupt marker is reconciled at the same time. (#4591)
|
||||
- [UI / Bug fix / Chore] Native assistant text now reconciles cleanly with committed transcript messages without duplicate streaming output. (#4593)
|
||||
- [Bug fix] The embedded browser pane no longer lingers over the welcome screen after switching or disconnecting from a server (#4595)
|
||||
- [Chore] Removed the "A new version of Omnigent is available" prompt and browser PWA install support; the desktop and mobile apps remain the installable clients. (#4617)
|
||||
- [Chore] OpenTelemetry exporters and automatic instrumentors are now installed through `omnigent[tracing]` instead of the default package. (#4621)
|
||||
- [Bug fix] Development builds no longer show an update reminder for the matching final release. (#4628)
|
||||
- [Bug fix] `omni host` no longer prints a zygote traceback — and keeps copy-on-write runner forking — when started from a directory that contains an `omnigent` checkout (#4631)
|
||||
- [UI / Bug fix] Clicking a file an agent mentions in its reply now opens it, including `path:line` citations and markdown links (#4644)
|
||||
- [UI / Bug fix] Fixed long unbroken text or inline code in chat messages overflowing or getting cut off at narrow window widths. (#4651)
|
||||
- [Bug fix] Fixed a duplicate assistant message that could appear after reconnecting to a Claude Code (native) session (#4656)
|
||||
- [Bug fix / Chore] Resuming or forking a Claude Code session containing screenshots no longer duplicates image payloads into metadata and inflates the request past the context limit. (#4659)
|
||||
- [UI / Bug fix] Archiving the current session now redirects to the home page instead of leaving you on the archived session (#4671)
|
||||
- [UI / Feature] Usage page shows session costs, daily spend timeline, and breakdowns by harness and model (#4673)
|
||||
- [Bug fix] Sessions whose workspace is an omnigent checkout no longer run a different omnigent than the one you installed (#4688)
|
||||
- [Bug fix] `omnigent run --harness acp:<agent>` now launches the ACP agent you asked for instead of the first one configured (#4689)
|
||||
- [UI / Bug fix] The desktop app now auto-selects this machine after "Run on this machine" (#4691)
|
||||
- [UI / Bug fix] The managed sandbox host option (and other capability-gated UI) now appears on its own after a slow `/v1/info` probe, instead of staying hidden until a page reload. (#4694)
|
||||
- [Chore] Runner-backed resource APIs now avoid redundant session reads, improving responsiveness under load. (#4695)
|
||||
- [Bug fix] ACP agents now report cached-read tokens, so token usage reflects what was actually billed (#4699)
|
||||
- [Bug fix] Built-in ACP agents like Grok Build now appear in `omni setup` instead of being invisible (#4700)
|
||||
- [Bug fix] `omnigent run --harness acp:<slug>` now works with remote servers by resolving the slug client-side and embedding the full agent config in the spec. ACP agent settings (session_id_mode, send_model, omnigent_mcp, env_passthrough) are now preserved through embedding. (#4702)
|
||||
- [Bug fix / Feature] `/model` now switches an ACP agent's model mid-conversation instead of being ignored, and keeps the chat history (#4703)
|
||||
- [Bug fix] A host name set in `config.yaml` is now kept when no `host_id` is present — the id is generated instead of overwriting your chosen name. (#4708)
|
||||
- [Bug fix] `omnigent resume` now lists only your own sessions, not ones shared with you (#4709)
|
||||
- [UI / Bug fix] The Inbox count badge now uses the same text and background colors as the selected session item in the sidebar (#4714)
|
||||
- [UI / Feature] Multi-session delete now shows a table of worktree branches you can pick to clean up (with a tri-state select-all header), instead of blocking branch cleanup behind single-session delete (#4715)
|
||||
- [Bug fix] OpenCode 1.18.x installs are now accepted; the version gate no longer rejects users who installed OpenCode via its official upstream route. (#4725)
|
||||
- [UI / Bug fix / Chore / Test/CI] Approval prompts now name the assistant that asked (Claude Code, Codex, Cursor, Antigravity, Kiro, Goose, Qwen Code, Hermes) instead of an internal policy id (#4735)
|
||||
- [UI / Bug fix] Opening the workspace folder browser no longer flashes an "Up one level" tooltip over the listing (#4742)
|
||||
- [Feature] Kubernetes sandbox runners now use Jobs with automatic restart on crash (up to 3 retries) and a liveness probe, replacing bare Pods that required manual intervention after a failure. (#4744)
|
||||
- [UI / Bug fix] The chat transcript now detects a silently dead live connection and reconnects on its own — worst case 45 s, instantly on tab refocus — instead of freezing until the page is reloaded. (#4750)
|
||||
- [Bug fix] Chat messages sent while the terminal's Claude composer is covered by the ctrl+r history search or a hand-opened `/model` picker now dismiss the overlay and deliver, instead of silently vanishing (#4751)
|
||||
- [Bug fix] Creating a session from the web UI is faster end-to-end: the chat page opens immediately and the terminal is ready sooner — including the first session after a host restart, which no longer pays a multi-second warmup. (#4752)
|
||||
- [UI / Bug fix] Answered question and plan cards stay outside the "Worked for" fold, read as settled, and survive a page reload (#4760)
|
||||
- [UI / Feature] Web terminals now attach directly over loopback when the runner is on the same machine as the browser, cutting keystroke echo from ~250 ms to under 10 ms against a remote server. (#4763)
|
||||
- [UI / Bug fix / Test/CI] The Sessions filter is now always visible, so session filtering is discoverable without hovering the sidebar header. (#4764)
|
||||
- [Feature] New `OMNIGENT_REQUIRE_WRAPPER` env var lets operators require the CLI be run through a wrapper (e.g. `isaac omni`) and refuse direct `omni` calls (#4766)
|
||||
- [UI / Bug fix / Chore / Test/CI] Chat output stays visible above a growing composer; bottom-following readers remain pinned while readers who scroll up—even just 50px—keep the same visible content. (#4767)
|
||||
- [Bug fix] `omnidev --vite-port` once again starts the frontend on the requested port. (#4770)
|
||||
- [Feature / Test/CI] macOS desktop app now ships an Intel (x64) build alongside Apple Silicon. (#4772)
|
||||
- [UI / Feature / Docs] Usage and web-driven harness setup can now be enabled per deployment with `OMNIGENT_FEATURES`. (#4775)
|
||||
- [UI / Bug fix / Feature] In-chat errors now provide expandable diagnostics and recovery-aware Retry, copy, and dismiss actions without replaying failed input, and cancelled retry requests no longer leave stale recovery results cached. (#4787)
|
||||
- [Feature / Docs] ArgoCD quick-start overlay for deploying Omnigent with the kubernetes sandbox provider (#4788)
|
||||
- [Bug fix] `omnigent[antigravity]` no longer crashes with a protobuf gencode/runtime version mismatch on startup. (#4795)
|
||||
- [UI / Bug fix] Server URLs in copyable connection and reconnect commands are now safely quoted. (#4817)
|
||||
- [Bug fix] Resumed Codex conversations now keep the authentication provider selected in Codex configuration. (#4818)
|
||||
- [Bug fix] `omnidev omnigent` commands now keep runtime state and configuration inside their development pod. (#4822)
|
||||
- [Bug fix] Native Windows: harness CLIs (codex, pi, claude-sdk, antigravity) no longer hang on spawn due to missing `SYSTEMROOT`/`COMSPEC`; Windows drive-letter workspace paths (`C:\…`) are now accepted; pre-release harness CLI versions (e.g. `0.146.0-alpha.9.2`) no longer fail the version gate. (#4886)
|
||||
- [UI / Feature] Background tasks that keep running after a turn ends now show as a pill above the composer instead of a "Working…" spinner (#4893)
|
||||
- [UI / Bug fix] Maximizing the workspace panel in the desktop app no longer tucks its tab icons under the macOS window controls. (#4897)
|
||||
- [Feature] Configured ACP agents (e.g. Devin) and installed ACP CLI harnesses (e.g. Grok Build) now appear in the web New Chat picker, like the native harnesses (#4909)
|
||||
- [Bug fix] Managed codex, claude-sdk (Polly/Debby), and pi harnesses resolve their launch model from the workspace's Unity Catalog model services, so they no longer fail against a Databricks AI Gateway that has retired the legacy `databricks-*` model namespace (#4915)
|
||||
- [UI / Feature] Devin is now a built-in harness — set it up in `omni setup`, launch it with `--harness devin` or from the New Chat picker, no `acp:` config needed (#4920)
|
||||
- [Bug fix] `omni setup` and the New Chat picker no longer show a duplicate — or silently ignore — a built-in ACP harness you configured yourself under the same name; your own command wins. (#4927)
|
||||
- [UI] Chat error banners are now a compact centered pill with inline Retry, matching the design prototype (#4931)
|
||||
- [UI / Feature] The chat header now shows the conversation's name, its project folder, and the sub-agent path, and title bars are a consistent 48px. (#4940)
|
||||
|
||||
## [v0.9.0] — 2026-08-11
|
||||
|
||||
- [UI / Bug fix] Recent servers remain one-click connectable and now include a separate copy action. (#2555)
|
||||
|
||||
+10
-6
@@ -85,19 +85,23 @@ cd omnigent
|
||||
|
||||
uv python install
|
||||
uv venv --python "$(cat .python-version)"
|
||||
uv sync --extra all --extra dev
|
||||
uv sync --extra all --group dev
|
||||
source .venv/bin/activate # or prefix commands with `uv run`
|
||||
```
|
||||
|
||||
Repository-only dependencies use PEP 735 groups: `lint` for static checks and
|
||||
code generation, `test` for pytest, and `dev` for both. Product capabilities
|
||||
remain installable extras. Plain `uv sync` installs neither group by default.
|
||||
|
||||
Common checks:
|
||||
|
||||
Pyrefly is the canonical Python type checker for the repository.
|
||||
|
||||
```bash
|
||||
uv run pytest # Python tests (e2e/live skipped by default)
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
uv run --no-sync pyrefly check # Python type checking (core and client SDK)
|
||||
uv run pre-commit run --all-files
|
||||
uv run --no-sync pytest # Python tests (e2e/live skipped by default)
|
||||
uv run --no-sync ruff check . && uv run --no-sync ruff format --check .
|
||||
uv run --no-sync pyrefly check # Python type checking (core and client SDK)
|
||||
uv run --no-sync pre-commit run --all-files
|
||||
```
|
||||
|
||||
When touching `web/`:
|
||||
@@ -135,7 +139,7 @@ test. A fresh worktree needs its own Python environment first:
|
||||
|
||||
```bash
|
||||
cd /path/to/omnigent-worktree
|
||||
uv sync --extra all --extra dev
|
||||
uv sync --extra all --group dev
|
||||
omnidev
|
||||
```
|
||||
|
||||
|
||||
@@ -365,8 +365,10 @@ Face Spaces**, **Modal**, **Cloudflare** (serverless, scale-to-zero), and
|
||||
covered too — and a **Cloudflare quick tunnel** (public) or **Tailscale**
|
||||
(private) reaches a server running on your own laptop without a deploy. The
|
||||
server can also provision a cloud sandbox per session (*managed hosts*), so no
|
||||
laptop has to stay online. The full menu of targets, the database options, and
|
||||
the sandbox setup live in
|
||||
laptop has to stay online. The full menu of targets, the database options, the
|
||||
sandbox setup, and
|
||||
[branding/white-labeling](https://github.com/omnigent-ai/omnigent/blob/main/deploy/README.md#branding-white-labeling)
|
||||
live in
|
||||
[`deploy/README.md`](https://github.com/omnigent-ai/omnigent/blob/main/deploy/README.md).
|
||||
|
||||
Once the server is up, sign in and register your laptop as a host:
|
||||
|
||||
@@ -498,6 +498,40 @@ to set and sanitize the identity header, and read
|
||||
[`docker/README.md#header-proxy-mode-for-deploys-behind-an-existing-sso-proxy`](docker/README.md#header-proxy-mode-for-deploys-behind-an-existing-sso-proxy)
|
||||
first.
|
||||
|
||||
## Branding (white-labeling)
|
||||
|
||||
Customize the app name, landing heading, and logos with a `branding:` block in
|
||||
the server config (`omnigent server -c config.yaml`, or `<data_dir>/config.yaml`
|
||||
— `/data/config.yaml` in the Docker stack). Takes effect on the next server
|
||||
start.
|
||||
|
||||
```yaml
|
||||
branding:
|
||||
app_name: "Acme Agent" # tab title, sidebar wordmark, login screen
|
||||
heading: "How can I help?" # landing hero; "" hides it, omit to keep the default
|
||||
logo: # a bare string sets `main`; or per-variant:
|
||||
main: logo.png # branding-assets/logo.png
|
||||
loading: loading.webp # working indicator (falls back to main)
|
||||
favicon: favicon.png # browser-tab icon
|
||||
powered_by: true # "Powered by Omnigent" credit; false to hide
|
||||
```
|
||||
|
||||
Logo files must live under a dedicated `branding-assets/` directory beside the
|
||||
config file (for example, `/data/branding-assets/logo.png`). PNG, JPEG, GIF,
|
||||
WebP, and ICO files up to 5 MiB are accepted only after full decoder validation.
|
||||
ICO files must contain only PNG-backed entries; every directory entry is bounded
|
||||
and decoded independently, while DIB/BMP-backed entries are rejected. Malformed,
|
||||
truncated, oversized, overlapping, trailing-payload, SVG, symlinked, escaped, and
|
||||
non-image files are ignored. Images are also bounded to 4096 pixels per side,
|
||||
128 frames, 16 megapixels per frame, and 64 megapixels across all decoded frames.
|
||||
The values are served over the unauthenticated `GET /v1/info` and
|
||||
`GET /v1/branding/logo/<variant>` endpoints so the login screen is branded before
|
||||
sign-in. Any unset field keeps its built-in default, so a partial block is fine.
|
||||
|
||||
The small "Powered by Omnigent" credit under the landing composer appears only
|
||||
once you set custom branding; `powered_by: false` hides it even then. It always
|
||||
shows the Omnigent mascot, never your logo.
|
||||
|
||||
## Adding a new deploy target
|
||||
|
||||
Drop a new subdirectory under `deploy/<target>/` with a `README.md`
|
||||
|
||||
@@ -147,6 +147,17 @@ UC Volume wheel paths because `uv lock` validates path sources locally.
|
||||
|
||||
Re-running is safe — every step is idempotent.
|
||||
|
||||
Release features are off by default. Enable one or more for the whole app by
|
||||
adding the comma-separated deploy argument, then reload the web app after the
|
||||
redeploy:
|
||||
|
||||
```bash
|
||||
--features usage_page,harness_install
|
||||
```
|
||||
|
||||
See [`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for the current
|
||||
inventory and rollback procedure.
|
||||
|
||||
> [!TIP]
|
||||
> To lock against a private PyPI mirror or proxy instead of public
|
||||
> PyPI, set `UV_INDEX_URL` before running `deploy.py`.
|
||||
|
||||
@@ -21,6 +21,9 @@ variables:
|
||||
UC schema (catalog.schema) holding the OTel destination tables.
|
||||
The platform writes to <schema>.otel_logs, otel_metrics, otel_spans.
|
||||
default: main.omnigent_logs
|
||||
features:
|
||||
description: "Comma-separated deployment-wide release features."
|
||||
default: ""
|
||||
|
||||
resources:
|
||||
apps:
|
||||
@@ -44,6 +47,8 @@ resources:
|
||||
value_from: artifact_volume
|
||||
- name: OTEL_TRACES_SAMPLER
|
||||
value: 'always_on'
|
||||
- name: OMNIGENT_FEATURES
|
||||
value: "${var.features}"
|
||||
resources:
|
||||
- name: postgres
|
||||
postgres:
|
||||
|
||||
@@ -567,6 +567,14 @@ def _parse_args() -> argparse.Namespace:
|
||||
"<schema>.otel_{logs,metrics,spans}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--features",
|
||||
default="",
|
||||
help=(
|
||||
"Comma-separated deployment-wide release features, e.g. "
|
||||
"'usage_page'. Empty keeps every release feature off."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
default="prod",
|
||||
@@ -746,6 +754,8 @@ def _bundle_vars(args: argparse.Namespace) -> list[str]:
|
||||
f"volume_name={args.volume_name}",
|
||||
"--var",
|
||||
f"otel_table_schema={args.otel_table_schema}",
|
||||
"--var",
|
||||
f"features={args.features}",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ POSTGRES_PASSWORD=change-me-please
|
||||
# Host port the omnigent container is published on. Default 8000.
|
||||
# OMNIGENT_PORT=8000
|
||||
|
||||
# ── Release features ─────────────────────────────────────
|
||||
# Comma-separated deployment-wide release features. Empty/unset keeps every
|
||||
# release feature off. Unknown names fail startup so typos cannot silently
|
||||
# change rollout behavior. Current keys: usage_page, harness_install.
|
||||
# OMNIGENT_FEATURES=usage_page
|
||||
|
||||
# ── Image ────────────────────────────────────────────────
|
||||
# The compose stack pulls a pre-built image from GHCR (built by CI on
|
||||
# every main-branch merge). Default: ghcr.io/omnigent-ai/omnigent-server.
|
||||
@@ -153,6 +159,13 @@ POSTGRES_PASSWORD=change-me-please
|
||||
|
||||
# ── Optional OIDC tuning ─────────────────────────────────
|
||||
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
|
||||
#
|
||||
# Absolute lifetime (days) of login-issued refresh grants — how long a
|
||||
# host/CLI that logged in with `omnigent login` can keep renewing its
|
||||
# access before a human must log in again. Default 30. Unattended hosts
|
||||
# renew automatically within this window; each host replica needs its
|
||||
# own login (refresh tokens rotate — shared copies revoke each other).
|
||||
# OMNIGENT_GRANT_MAX_LIFETIME_DAYS=30
|
||||
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
|
||||
#
|
||||
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
|
||||
|
||||
@@ -39,6 +39,27 @@ Reset everything (drops the DB and the artifact store):
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Release features
|
||||
|
||||
Release features are deployment-wide and off by default. Enable one or more
|
||||
with the comma-separated `OMNIGENT_FEATURES` variable in `.env`, then recreate
|
||||
the server container:
|
||||
|
||||
```dotenv
|
||||
OMNIGENT_FEATURES=usage_page
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
curl -s http://localhost:8000/v1/info | jq '.features'
|
||||
```
|
||||
|
||||
Known keys and their lifecycle are documented in
|
||||
[`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md). Unknown keys fail
|
||||
server startup so a typo cannot silently produce the wrong rollout. To roll
|
||||
back, remove the key (or empty the variable), run `docker compose up -d` again,
|
||||
and reload the web app.
|
||||
|
||||
## Multi-user mode (accounts — default)
|
||||
|
||||
Built-in accounts auth: no IdP to register, no proxy to host.
|
||||
|
||||
@@ -47,3 +47,16 @@ allowed_domains:
|
||||
# the built-in defaults (20 files / 256 MiB total).
|
||||
# copy_max_files: 20
|
||||
# copy_max_total_bytes: 268435456
|
||||
|
||||
# Branding / white-labeling. Customize the app name, landing heading, and
|
||||
# logos shown in the web UI. Logo files must live under branding-assets/ beside
|
||||
# this config file; served pre-auth so the login screen is branded too. Any unset
|
||||
# field keeps its built-in default. See deploy/README.md#branding-white-labeling.
|
||||
# branding:
|
||||
# app_name: "Acme Agent" # tab title, sidebar wordmark, login screen
|
||||
# heading: "How can I help?" # landing hero; "" hides it, omit for the default
|
||||
# logo: # a bare string sets `main`; or per-variant:
|
||||
# main: logo.png # hero / primary mark
|
||||
# loading: loading.webp # working indicator (falls back to main)
|
||||
# favicon: favicon.png # browser-tab icon
|
||||
# powered_by: true # "Powered by Omnigent" credit (only when branded); false to hide
|
||||
|
||||
@@ -62,6 +62,9 @@ services:
|
||||
ARTIFACT_DIR: /data/artifacts
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
# Comma-separated deployment-wide release features. Empty means all
|
||||
# release features are off; see .env.example for the known keys.
|
||||
OMNIGENT_FEATURES: "${OMNIGENT_FEATURES:-}"
|
||||
# Anchor the server's data dir on the persistent volume so
|
||||
# file-backed operator config survives container restarts:
|
||||
# the admin roster (/data/admins) and allowed-domains file
|
||||
|
||||
@@ -304,6 +304,11 @@ def _build_routing(
|
||||
return _build_local_llm_routing_client(server_llm), settings
|
||||
|
||||
|
||||
def _resolve_execution_timeout(cfg: dict[str, Any]) -> int:
|
||||
"""Return the configured execution limit or the RuntimeCaps default."""
|
||||
return int(cfg.get("execution_timeout") or 7200)
|
||||
|
||||
|
||||
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
"""Resolve config if needed, wire the stores, and build the app.
|
||||
|
||||
@@ -374,6 +379,7 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
routing_client, routing_settings = _build_routing(cfg, server_llm)
|
||||
|
||||
caps = RuntimeCaps(
|
||||
execution_timeout=_resolve_execution_timeout(cfg),
|
||||
default_policies=parse_default_policies(cfg.get("policies")),
|
||||
llm=server_llm,
|
||||
routing_client=routing_client,
|
||||
|
||||
@@ -91,6 +91,22 @@ Apply your chosen issuer with `kubectl apply -f <file>`. Without it, cert-manage
|
||||
logs `IssuerNotFound` and no certificate is issued (the server still runs — only
|
||||
TLS is affected).
|
||||
|
||||
## Release features
|
||||
|
||||
Release features are deployment-wide and off by default. Set the
|
||||
comma-separated `OMNIGENT_FEATURES` value in `base/configmap.yaml`, apply your
|
||||
Kustomize target, and restart the Deployment so every pod receives one fresh
|
||||
startup snapshot:
|
||||
|
||||
```bash
|
||||
kubectl kustomize deploy/kubernetes/base/ | kubectl apply -f -
|
||||
kubectl rollout restart deployment/omnigent
|
||||
kubectl rollout status deployment/omnigent
|
||||
```
|
||||
|
||||
Use the same restart after removing a feature for rollback. See
|
||||
[`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for known keys.
|
||||
|
||||
## Deploy with an external database
|
||||
|
||||
Use this path when you have a managed Postgres (RDS, Cloud SQL, Neon, etc.).
|
||||
@@ -270,6 +286,38 @@ kubectl apply -k deploy/kubernetes/overlays/sandbox-runners
|
||||
Both are detailed in
|
||||
[`overlays/sandbox-runners/README.md`](overlays/sandbox-runners/README.md#server-auth-managed-hosts).
|
||||
|
||||
## Deploy with ArgoCD
|
||||
|
||||
The `overlays/argocd/` overlay adds ArgoCD safety annotations (`Prune=false` on
|
||||
stateful resources, `ignoreDifferences` for operator-managed Secrets) onto
|
||||
`sandbox-runners`. ArgoCD renders Kustomize natively — no plugin needed.
|
||||
|
||||
**Prerequisites:** fork the repo and replace the placeholder values in
|
||||
`base/secret.yaml` in your fork (ArgoCD reads from Git, not your disk). The
|
||||
default `accounts` auth provider refuses the managed runner dial-back — use
|
||||
header/OIDC auth or single-user (see
|
||||
[sandbox-runners README § Server auth](overlays/sandbox-runners/README.md#server-auth-managed-hosts)).
|
||||
|
||||
```bash
|
||||
# 1. Edit application.yaml — set repoURL to your fork, targetRevision to your
|
||||
# branch — then apply:
|
||||
kubectl apply -f deploy/kubernetes/overlays/argocd/application.yaml
|
||||
|
||||
# 2. Wait for ArgoCD to create the runner namespace (up to 3 min without a webhook):
|
||||
kubectl wait --for=jsonpath='{.status.phase}'=Active \
|
||||
namespace/omnigent-sandboxes --timeout=300s
|
||||
|
||||
# 3. Create the harness-credentials Secret (see sandbox-runners README):
|
||||
kubectl create secret generic omnigent-creds -n omnigent-sandboxes \
|
||||
--from-literal=ANTHROPIC_API_KEY=sk-ant-... \
|
||||
--from-literal=OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
For production, manage `omnigent-creds` with
|
||||
[sealed-secrets](https://github.com/bitnami-labs/sealed-secrets) or
|
||||
[external-secrets](https://external-secrets.io/). See
|
||||
[`overlays/argocd/README.md`](overlays/argocd/README.md) for the full guide.
|
||||
|
||||
## Verify the deployment
|
||||
|
||||
Check the rollout and reach the server without a public domain:
|
||||
|
||||
@@ -8,6 +8,8 @@ data:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8000"
|
||||
ARTIFACT_DIR: "/data/artifacts"
|
||||
# Comma-separated release features; empty keeps every feature off.
|
||||
OMNIGENT_FEATURES: ""
|
||||
OMNIGENT_ADMIN_CREDENTIALS_PATH: "/data/admin-credentials"
|
||||
OMNIGENT_AUTH_ENABLED: "1"
|
||||
OMNIGENT_AUTH_PROVIDER: "accounts"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# ArgoCD overlay
|
||||
|
||||
Deploy Omnigent with the kubernetes sandbox provider via ArgoCD. This overlay
|
||||
adds safety annotations onto the
|
||||
[`sandbox-runners`](../sandbox-runners/README.md) overlay:
|
||||
|
||||
- **`Prune=false`** on Namespaces and the artifact PVC, so an accidental prune
|
||||
or Application deletion does not cascade to operator-created Secrets and
|
||||
runner Pods.
|
||||
- **Ingress in wave 1**, so its health check (which requires an ingress
|
||||
controller) does not gate the rest of the sync.
|
||||
|
||||
ArgoCD's built-in kind ordering already applies resources in dependency order
|
||||
(Namespace → SA → Role → ConfigMap → Secret → Service → Deployment → Ingress),
|
||||
so explicit sync-wave ordering for every resource is unnecessary.
|
||||
|
||||
ArgoCD renders Kustomize natively — no plugin or Helm chart needed.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. **Fork the repo** — ArgoCD reads from Git, not your local disk. All edits
|
||||
below go into your fork and must be committed and pushed to the branch
|
||||
`targetRevision` names (default: `HEAD` / your default branch).
|
||||
|
||||
2. **Replace placeholder secrets** — `base/secret.yaml` ships `changeme`
|
||||
values. In your fork, set real values and commit:
|
||||
|
||||
```yaml
|
||||
# deploy/kubernetes/base/secret.yaml
|
||||
DATABASE_URL: "postgresql+psycopg://user:pass@your-db-host:5432/omnigent"
|
||||
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "<run: openssl rand -hex 32>"
|
||||
```
|
||||
|
||||
For production, manage `omnigent-secrets` externally (sealed-secrets or
|
||||
external-secrets) and remove `secret.yaml` from the overlay render with a
|
||||
`$patch: delete` — see `openshift/kustomization.yaml:12-20` for the pattern.
|
||||
The Application's `ignoreDifferences` entry prevents `selfHeal` from
|
||||
reverting out-of-band edits to this Secret's data.
|
||||
|
||||
3. **Configure server auth** — the default `accounts` provider refuses the
|
||||
managed runner dial-back (`403`). Front the server with **header or OIDC
|
||||
auth**, or run single-user. See
|
||||
[`sandbox-runners/README.md` § Server auth](../sandbox-runners/README.md#server-auth-managed-hosts).
|
||||
|
||||
4. **Set your domain** *(optional)* — replace `omnigent.example.com` in
|
||||
`base/ingress.yaml`. To skip the Ingress entirely, add a `$patch: delete`
|
||||
in your fork's overlay (see `openshift/kustomization.yaml:12-20` for the
|
||||
pattern — do not delete `base/ingress.yaml` itself, as it is shared by all
|
||||
overlays).
|
||||
|
||||
5. **Edit and apply the Application CR:**
|
||||
|
||||
```bash
|
||||
# In application.yaml, set repoURL to your fork and targetRevision to
|
||||
# the branch you pushed to:
|
||||
kubectl apply -f deploy/kubernetes/overlays/argocd/application.yaml
|
||||
```
|
||||
|
||||
6. **Wait for the sync** — ArgoCD creates the namespaces asynchronously (up
|
||||
to 3 minutes without a webhook). Wait before creating the harness Secret:
|
||||
|
||||
```bash
|
||||
kubectl wait --for=jsonpath='{.status.phase}'=Active \
|
||||
namespace/omnigent-sandboxes --timeout=300s
|
||||
```
|
||||
|
||||
7. **Create the harness-credentials Secret** — LLM API keys for runner Pods.
|
||||
Not in Git (credentials don't belong there):
|
||||
|
||||
```bash
|
||||
kubectl create secret generic omnigent-creds -n omnigent-sandboxes \
|
||||
--from-literal=ANTHROPIC_API_KEY=sk-ant-... \
|
||||
--from-literal=OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
For production, manage this with
|
||||
[sealed-secrets](https://github.com/bitnami-labs/sealed-secrets) or
|
||||
[external-secrets](https://external-secrets.io/).
|
||||
|
||||
## What ArgoCD does not create
|
||||
|
||||
ArgoCD does not *create* these resources — but it **owns the namespaces they
|
||||
live in**. Deleting the Application (with the default finalizer) deletes both
|
||||
namespaces and garbage-collects everything inside them, including:
|
||||
|
||||
- **`omnigent-creds` Secret** (step 7 above) — without it, runner Pods stall
|
||||
in `CreateContainerConfigError`. See the
|
||||
[sandbox-runners README](../sandbox-runners/README.md#apply) for which keys
|
||||
to set.
|
||||
- **OIDC / external-auth Secrets** — if you front the server with OIDC, create
|
||||
the provider Secret separately (see the
|
||||
[base README](../../README.md#use-your-own-idp-instead-oidc--optional)).
|
||||
|
||||
The `Prune=false` annotations protect Namespaces and the PVC during **sync**
|
||||
(accidental prune from a Git rename), but the Application finalizer bypasses
|
||||
them on **deletion**. To make `kubectl delete application` orphan resources
|
||||
instead of cascading, remove the `resources-finalizer.argocd.argoproj.io`
|
||||
finalizer from `application.yaml`.
|
||||
|
||||
## What automated sync does
|
||||
|
||||
- **`prune: true`** — resources that leave Git are deleted from the cluster on
|
||||
the next sync. `Prune=false` annotations on Namespaces and the PVC exempt
|
||||
them.
|
||||
- **`selfHeal: true`** — manual cluster edits are reverted to match Git.
|
||||
`ignoreDifferences` on `omnigent-secrets` and `omnigent-artifacts` exempts
|
||||
their data, so out-of-band credential edits and volume expansions are kept.
|
||||
- **Deleting the Application** — with the finalizer, deletes both namespaces,
|
||||
the artifact PVC, and everything inside them. Without it, orphans everything.
|
||||
|
||||
## Customizing
|
||||
|
||||
Fork the repo, edit, commit, and push — ArgoCD picks up changes on the next
|
||||
sync. Common adjustments:
|
||||
|
||||
- **Sandbox config** — `../sandbox-runners/sandbox-config.yaml` (namespace,
|
||||
image, node selector, resource limits, PVC mounts). Note: changes to
|
||||
ConfigMaps require a Pod restart to take effect (the server reads config at
|
||||
startup). Use `configMapGenerator` with a name-suffix hash to trigger an
|
||||
automatic rollout, or restart the Deployment manually after sync.
|
||||
- **Server resources** — `../../base/deployment.yaml`.
|
||||
- **Ingress** — `../../base/ingress.yaml` (hostname, TLS, annotations). To
|
||||
remove the Ingress, add a `$patch: delete` in the overlay (see
|
||||
`openshift/kustomization.yaml`).
|
||||
- **In-cluster Postgres** — use `overlays/openshift-postgres/` as a reference
|
||||
for composing two overlays that share a base; adding `../postgres/` as a
|
||||
direct resource causes a duplicate-base error. Alternatively, apply the
|
||||
Postgres StatefulSet separately.
|
||||
|
||||
## ApplicationSet (multi-environment)
|
||||
|
||||
For staging/production splits, use an ArgoCD
|
||||
[ApplicationSet](https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/)
|
||||
with a list generator. Point each entry at a different `targetRevision` (branch)
|
||||
or fork the overlay directory per environment with its own config values.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
# Sample ArgoCD Application CR for deploying Omnigent with the kubernetes
|
||||
# sandbox provider. Fork the repo, edit config/secrets in your fork, then
|
||||
# set repoURL and targetRevision below. Apply to the argocd namespace.
|
||||
#
|
||||
# ArgoCD renders the Kustomize output natively — no Helm chart or plugin needed.
|
||||
#
|
||||
# TWO SECRETS ARE NOT IN GIT and must be created out of band (or via
|
||||
# sealed-secrets / external-secrets):
|
||||
#
|
||||
# 1. omnigent-secrets — DATABASE_URL + cookie secret (see base/secret.yaml
|
||||
# for the keys; replace the placeholder values in your fork, or manage
|
||||
# the Secret externally and remove secret.yaml from the overlay render
|
||||
# with a $patch: delete — see openshift/kustomization.yaml for the
|
||||
# pattern).
|
||||
# 2. omnigent-creds — harness LLM API keys for runner Pods (see the
|
||||
# sandbox-runners README).
|
||||
#
|
||||
# DELETION WARNING: the resources-finalizer below means
|
||||
# `kubectl delete application omnigent` deletes every tracked resource,
|
||||
# including both Namespace objects and the artifact PVC. The overlay's
|
||||
# Prune=false annotations prevent accidental pruning during sync, but the
|
||||
# finalizer bypasses them on Application deletion. Remove the finalizer
|
||||
# if you want `kubectl delete application` to orphan resources instead of
|
||||
# cascading.
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: omnigent
|
||||
namespace: argocd
|
||||
finalizers:
|
||||
- resources-finalizer.argocd.argoproj.io
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://github.com/omnigent-ai/omnigent.git
|
||||
targetRevision: HEAD
|
||||
path: deploy/kubernetes/overlays/argocd
|
||||
destination:
|
||||
# Resources set their own namespaces explicitly (omnigent and
|
||||
# omnigent-sandboxes), so destination.namespace is not injected.
|
||||
server: https://kubernetes.default.svc
|
||||
ignoreDifferences:
|
||||
# The checked-in secret.yaml ships placeholder values. Operators replace
|
||||
# them out of band (kubectl edit, sealed-secrets, external-secrets), and
|
||||
# selfHeal must not revert those edits. Without this, selfHeal
|
||||
# continuously overwrites live credentials with the placeholder.
|
||||
#
|
||||
# The pointer targets /data, not /stringData, because ArgoCD normalizes
|
||||
# stringData into base64 /data on the live object before diffing.
|
||||
- group: ""
|
||||
kind: Secret
|
||||
name: omnigent-secrets
|
||||
namespace: omnigent
|
||||
jsonPointers:
|
||||
- /data
|
||||
# The API server mutates spec.resources.requests.storage on PVC creation
|
||||
# (rounding, defaulting). selfHeal would report a perpetual diff.
|
||||
- group: ""
|
||||
kind: PersistentVolumeClaim
|
||||
name: omnigent-artifacts
|
||||
namespace: omnigent
|
||||
jsonPointers:
|
||||
- /spec/resources/requests/storage
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
# Honour the ignoreDifferences entries above during sync, not just
|
||||
# during diff. Without this, a manual sync still overwrites the live
|
||||
# secret values even though the diff view hides them.
|
||||
- RespectIgnoreDifferences=true
|
||||
retry:
|
||||
limit: 3
|
||||
backoff:
|
||||
duration: 10s
|
||||
factor: 2
|
||||
maxDuration: 3m
|
||||
@@ -0,0 +1,46 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
# ArgoCD overlay for the kubernetes sandbox provider. Adds safety annotations
|
||||
# (Prune=false on stateful resources, Ingress in a late wave) onto the
|
||||
# sandbox-runners overlay. ArgoCD's built-in kind ordering already sequences
|
||||
# Namespace → SA → Role → ConfigMap → Secret → Service → Deployment → Ingress,
|
||||
# so explicit sync waves are not needed for ordering — only to prevent the
|
||||
# Ingress health gate from blocking the sync on clusters without a controller.
|
||||
|
||||
resources:
|
||||
- ../sandbox-runners
|
||||
|
||||
patches:
|
||||
# Namespaces and the artifact PVC must survive Application deletion and
|
||||
# accidental prune (a rename in base/ or a stale targetRevision). Without
|
||||
# Prune=false, `kubectl delete application omnigent` cascades through the
|
||||
# finalizer to both namespaces and everything inside them — including the
|
||||
# operator-created omnigent-creds Secret and any pvc_mounts claims.
|
||||
- target:
|
||||
kind: Namespace
|
||||
patch: |
|
||||
- op: add
|
||||
path: /metadata/annotations/argocd.argoproj.io~1sync-options
|
||||
value: Prune=false
|
||||
- target:
|
||||
kind: PersistentVolumeClaim
|
||||
patch: |
|
||||
- op: add
|
||||
path: /metadata/annotations/argocd.argoproj.io~1sync-options
|
||||
value: Prune=false
|
||||
|
||||
# The Ingress depends on an ingress controller (nginx by default) and
|
||||
# cert-manager. ArgoCD scores an Ingress without status.loadBalancer as
|
||||
# Progressing. Wave 1 (everything else is implicit wave 0) means no later
|
||||
# sync wave gates on its health, so the *sync* completes. The Application
|
||||
# itself may still report Progressing indefinitely on a cluster without a
|
||||
# controller — with automated + selfHeal, that keeps the Application in a
|
||||
# perpetual reconcile loop (harmless but noisy). Install the controller or
|
||||
# add a custom health check that treats the Ingress as healthy.
|
||||
- target:
|
||||
kind: Ingress
|
||||
patch: |
|
||||
- op: add
|
||||
path: /metadata/annotations/argocd.argoproj.io~1sync-wave
|
||||
value: "1"
|
||||
@@ -1,42 +1,43 @@
|
||||
# Kubernetes sandbox runners (on-demand host Pods)
|
||||
|
||||
This Kustomize overlay turns on the **`kubernetes`** managed-sandbox provider: a
|
||||
`host_type: managed` session spawns one **runner Pod** that runs `omnigent host`
|
||||
as its container entrypoint and dials back to the server over the existing
|
||||
launch-token tunnel. It layers the RBAC + config the provider needs onto the
|
||||
base server deployment.
|
||||
`host_type: managed` session spawns a **batch/v1 Job** whose child Pod runs
|
||||
`omnigent host` as its container entrypoint and dials back to the server over the
|
||||
existing launch-token tunnel. It layers the RBAC + config the provider needs onto
|
||||
the base server deployment.
|
||||
|
||||
## Launch model: entrypoint-as-host
|
||||
|
||||
The runner Pod's container command **is** the host. An **init container**
|
||||
prepares the workspace (`mkdir` + optional `git clone`); the **main container**
|
||||
then runs `omnigent host` under a tiny PID-1 reaper. The host re-parents runner
|
||||
processes to PID 1, which the reaper reaps; SIGTERM is forwarded for graceful
|
||||
shutdown.
|
||||
The runner is launched as a **batch/v1 Job** (one Pod, `backoffLimit: 6`). The
|
||||
Job's child Pod runs `omnigent host` as its container command. An **init
|
||||
container** prepares the workspace (`mkdir` + optional `git clone`); the **main
|
||||
container** then runs `omnigent host` under a tiny PID-1 reaper. The host
|
||||
re-parents runner processes to PID 1, which the reaper reaps; SIGTERM is
|
||||
forwarded for graceful shutdown.
|
||||
|
||||
The launch token is delivered through a **per-Pod Kubernetes Secret** referenced
|
||||
The launch token is delivered through a **per-Job Kubernetes Secret** referenced
|
||||
by the Pod's `secretKeyRef` — it never enters the Pod spec, a command line, or
|
||||
an audit log. The launcher creates that Secret at provision and deletes it
|
||||
alongside the Pod at terminate.
|
||||
alongside the Job at terminate.
|
||||
|
||||
Because the host is **never started by `exec`-ing into an already-running
|
||||
container**, this provider needs **no `pods/exec` grant** — and avoids the
|
||||
exec-into-running-container class of runtime issues entirely. The server SA's
|
||||
rights are the minimum the launcher calls: create/get/delete Pods, get
|
||||
`pods/log` (start-failure diagnostics only), create/delete Secrets (the per-Pod
|
||||
token), and list events.
|
||||
rights are the minimum the launcher calls: create/get/delete Jobs,
|
||||
list/get Pods (to poll the Job's child), get `pods/log` (start-failure
|
||||
diagnostics only), create/delete Secrets (the per-Job token), and list events.
|
||||
|
||||
## Two-namespace, least-blast-radius design
|
||||
|
||||
| Namespace | Holds |
|
||||
|---|---|
|
||||
| `omnigent` | the server, its DB/PVC, its Secrets, the `omnigent-server` SA |
|
||||
| `omnigent-sandboxes` | runner Pods, the per-Pod token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
|
||||
| `omnigent-sandboxes` | runner Jobs (and their child Pods), the per-Job token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
|
||||
|
||||
The server SA's Pod/Secret rights are a **namespaced Role** bound (cross-namespace)
|
||||
to `omnigent-sandboxes` only — so a compromised server can manage runner Pods but
|
||||
**cannot** delete the server/DB Pods, read the server's Secrets, or execute
|
||||
commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
|
||||
The server SA's Job/Pod/Secret rights are a **namespaced Role** bound
|
||||
(cross-namespace) to `omnigent-sandboxes` only — so a compromised server can
|
||||
manage runner Jobs but **cannot** delete the server/DB Pods, read the server's
|
||||
Secrets, or execute commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
|
||||
the generated runner Pod is already restricted-compliant (non-root uid 1000, drop
|
||||
`ALL` caps, `seccompProfile: RuntimeDefault`, no privilege escalation).
|
||||
|
||||
@@ -83,7 +84,7 @@ runner Pod unexpectedly carries no credential:
|
||||
(`omnigent/server/managed_hosts.py`), e.g. "agent … is not a genuine built-in;
|
||||
omitting agent label".
|
||||
- A name that is not a valid label value logs a `WARNING` from
|
||||
`build_pod_manifest` (`omnigent/onboarding/sandboxes/kubernetes.py`), e.g.
|
||||
`build_job_manifest` (`omnigent/onboarding/sandboxes/kubernetes.py`), e.g.
|
||||
"agent … is not a valid omnigent.ai/agent value; runner Pod … stays
|
||||
unclassified". Note the gate upstream will already have logged this agent as
|
||||
classified, so this is the line that explains the missing label.
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
---
|
||||
# Namespaced Role granting the server EXACTLY what the entrypoint-as-host
|
||||
# launcher calls — nothing more (no watch, no pods/exec). Lives in the DEDICATED
|
||||
# runner namespace `omnigent-sandboxes` and is bound to the omnigent-server SA
|
||||
# (in `omnigent`) via the cross-namespace rolebinding.yaml. Because the grant is
|
||||
# a namespaced Role here, it can ONLY ever touch objects in `omnigent-sandboxes`,
|
||||
# never the server/DB Pods or Secrets in `omnigent`.
|
||||
# Namespaced Role granting what the entrypoint-as-host launcher needs (no watch,
|
||||
# no pods/exec). Lives in the DEDICATED runner namespace `omnigent-sandboxes`
|
||||
# and is bound to the omnigent-server SA (in `omnigent`) via the cross-namespace
|
||||
# rolebinding.yaml. Because the grant is a namespaced Role here, it can ONLY
|
||||
# ever touch objects in `omnigent-sandboxes`, never the server/DB Pods or
|
||||
# Secrets in `omnigent`.
|
||||
#
|
||||
# pods:create is retained temporarily for the bare-Pod → Job migration (see
|
||||
# TODO below). secrets:get is deliberately withheld — the launcher never reads
|
||||
# Secrets back, and the harness-credentials Secret is operator-managed.
|
||||
# pods/exec is withheld — the host is an entrypoint, not exec'd into.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
@@ -14,34 +19,31 @@ metadata:
|
||||
app.kubernetes.io/name: omnigent
|
||||
app.kubernetes.io/component: server
|
||||
rules:
|
||||
# Manage the lifecycle of runner Pods, scoped to exactly what the launcher
|
||||
# calls: provision_managed_host() creates them, the pod-start wait reads them
|
||||
# (read_namespaced_pod is a `get`), and terminate() deletes them. The launcher
|
||||
# never watches Pods, so `watch` is omitted. NOTE: there is deliberately NO
|
||||
# `pods/exec` grant — the host runs as the Pod's OWN entrypoint
|
||||
# (`omnigent host` under a PID-1 reaper), so the server never execs into a
|
||||
# running container. Dropping exec removes the most powerful grant a
|
||||
# compromised server could abuse (arbitrary in-Pod command execution).
|
||||
# Manage the lifecycle of runner Jobs. The launcher creates a batch/v1 Job
|
||||
# (which spawns a child Pod), reads the Job's child Pod to poll start
|
||||
# readiness, and deletes the Job (cascading to its Pods) at terminate.
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["create", "get", "delete"]
|
||||
# The launcher lists Pods by job-name label to find the Job's child Pod,
|
||||
# then reads it to poll for Running phase. create/delete are retained so
|
||||
# old-server (bare Pod) + new-Role doesn't break, and so terminate() can
|
||||
# fall back to deleting a bare Pod created before the Job migration.
|
||||
# TODO(v0.29): remove create/delete once all runners have rolled past v0.28.
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "delete"]
|
||||
# Start-failure diagnostics ONLY: when a Pod won't start, the launcher tails
|
||||
# the failed container's log (e.g. the init container's `git clone` error) so
|
||||
# the launch error names WHAT failed instead of a generic timeout. Read-only.
|
||||
verbs: ["create", "list", "get", "delete"]
|
||||
# Start-failure diagnostics ONLY: tails the failed container's log.
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
# The per-launch token rides a per-Pod Secret (referenced by the Pod's
|
||||
# `secretKeyRef`), so the launch token never enters the Pod spec or any
|
||||
# audit-logged surface. The launcher creates that Secret at provision and
|
||||
# deletes it alongside the Pod at terminate — hence create + delete (no get:
|
||||
# the launcher never reads Secrets back, and the harness-credentials Secret is
|
||||
# operator-managed, not touched here).
|
||||
# The per-launch token rides a per-Job Secret (referenced by the Pod's
|
||||
# `secretKeyRef`). The launcher creates that Secret before the Job and
|
||||
# deletes it alongside the Job at terminate.
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["create", "delete"]
|
||||
# Surface scheduler/kubelet events (FailedScheduling, Failed pull, …) in the
|
||||
# provider's error messages when a Pod won't become ready.
|
||||
# Surface scheduler/kubelet events in the provider's error messages.
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["list"]
|
||||
|
||||
@@ -404,6 +404,6 @@ upload, foreground streaming, attach, terminate, env passthrough, error handling
|
||||
and the managed-config parsing:
|
||||
|
||||
```bash
|
||||
uv pip install -e '.[openshell,dev]'
|
||||
pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py
|
||||
uv sync --extra openshell --group test
|
||||
uv run --no-sync pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py
|
||||
```
|
||||
|
||||
@@ -73,6 +73,14 @@ steps below are validated end-to-end:
|
||||
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
|
||||
> promptly after the deploy goes live.
|
||||
|
||||
## Release features
|
||||
|
||||
In the Omnigent service's **Variables** tab, set `OMNIGENT_FEATURES` to a
|
||||
comma-separated enabled set such as `usage_page`. Railway redeploys the service
|
||||
automatically. Remove the key from the value to roll back, then reload the web
|
||||
app. See [`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for known
|
||||
keys.
|
||||
|
||||
## Use your own IdP instead (OIDC)
|
||||
|
||||
Prefer GitHub / Google / Okta login over built-in accounts? Switch the provider
|
||||
|
||||
+88
-12
@@ -17,9 +17,11 @@
|
||||
> enforcement in `omnigent/server/auth.py` (`delegated_path_allowed`,
|
||||
> `set_grant_revocation_check`). Wired in `omnigent/server/app.py`,
|
||||
> **opt-in and default-off** via `OMNIGENT_DEVICE_GRANT_ENABLED` (the
|
||||
> `/oauth/*` routes are unmounted unless it is truthy), and then only in
|
||||
> **accounts** mode (OIDC delegates login to the IdP via the cli-ticket
|
||||
> flow and never mounts these routes).
|
||||
> `/oauth/device/*` consent routes are unmounted unless it is truthy), and
|
||||
> then only in **accounts** mode (OIDC delegates login to the IdP via the
|
||||
> cli-ticket flow and never mounts the consent routes). The token/revoke
|
||||
> half (`/oauth/token`, `/oauth/revoke`) mounts **unconditionally** in both
|
||||
> accounts and OIDC modes: login-issued refresh grants (below) need it.
|
||||
> Slack: `integrations/slack/src/omnigent_slack/oauth.py`,
|
||||
> `tokens.py` (Fernet-encrypted `oauth_tokens`), `auth_manager.py`, plus
|
||||
> the bearer/refresh wiring in `omnigent.py` (`ClientAuth`,
|
||||
@@ -182,15 +184,20 @@ approved it.
|
||||
|
||||
### Router `omnigent/server/routes/device_auth.py`
|
||||
|
||||
Mounted in `app.py` only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy**
|
||||
(opt-in, **default-off** — the `/oauth/*` routes are absent otherwise), and
|
||||
then **only in `accounts` mode** (OIDC delegates login to the IdP via the
|
||||
cli-ticket flow and never mounts these routes; header mode has no
|
||||
server-mintable identity — see `create_device_auth_router`, which raises if
|
||||
constructed for any other source). The `device_grants` table is created
|
||||
unconditionally by the migration regardless of the flag; only the router
|
||||
mount is gated. This router **owns** `mint_delegated_token` and
|
||||
`DELEGATED_SCOPE`.
|
||||
The RFC 8628 consent surface (`/oauth/device/*`) is mounted in `app.py`
|
||||
only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy** (opt-in,
|
||||
**default-off**), and then **only in `accounts` mode** (the in-browser
|
||||
consent needs the accounts login page; header mode has no server-mintable
|
||||
identity — see `create_device_auth_router`, which raises if constructed for
|
||||
any other source). The token/revoke half is factored into
|
||||
`create_oauth_token_router` and mounts **unconditionally** in both accounts
|
||||
and OIDC modes — login-issued refresh grants need `/oauth/token` even where
|
||||
the device flow is off; a standalone mount refuses the `device_code` grant
|
||||
type with `unsupported_grant_type`. The `device_grants` table is created
|
||||
unconditionally by the migration regardless of the flag; only the consent
|
||||
mount is gated. This router also **owns** `mint_delegated_token` and
|
||||
`DELEGATED_SCOPE` (moved here from `oidc.py`, which retains only
|
||||
`mint_session_token` / `mint_session_cookie`).
|
||||
|
||||
- `POST /oauth/device/authorize` — **public** (rate-limited). Generates a
|
||||
high-entropy `device_code` (`secrets.token_urlsafe`, stored **hashed**), a
|
||||
@@ -355,3 +362,72 @@ stateless. This added invariant is the main thing for reviewers to scrutinize.
|
||||
- Applying the same delegated grant to other non-browser clients (the CLI could
|
||||
use it too, superseding the in-memory `_cli_tickets` store).
|
||||
- Per-scope consent granularity beyond the single "session APIs, no admin" scope.
|
||||
|
||||
## Login-issued refresh grants
|
||||
|
||||
The fix for unattended hosts dying at session-JWT expiry (a host
|
||||
authenticated via `omnigent login` previously had **no renewal path** —
|
||||
the stored `{token, user_id, expires_at}` record simply lapsed, default
|
||||
8 h, and the next tunnel reconnect got a misleading 403).
|
||||
|
||||
- **Issuance** — both interactive login flows create a grant born
|
||||
`redeemed` (`DeviceGrantStore.create_redeemed_grant`; the interactive
|
||||
login *is* the consent step, so no device-code dance): the OIDC
|
||||
cli-ticket fulfillment always, and accounts `POST /auth/login` when the
|
||||
body carries `issue_refresh: true` (sent by the CLI, never the web
|
||||
form — a browser must not receive refresh material). The raw refresh
|
||||
token rides back once (`/auth/cli-poll` / the login response) as an
|
||||
optional `refresh_token` key — old CLIs ignore it, new CLIs against old
|
||||
servers see it absent. `client_id` is `"omnigent-cli"`.
|
||||
- **Authority** — a refreshed login-grant token carries `grant_id` (so
|
||||
revocation still kills it) but **no `scope` claim**, so the delegated
|
||||
path allowlist does not apply: it renews the session JWT and keeps that
|
||||
same authority. Scoping it like a third-party device grant instead made
|
||||
every non-allowlisted route (`/v1/usage`, `/v1/scheduled-tasks`,
|
||||
`/v1/policy-registry`) 401 after the first refresh. `LOGIN_GRANT_CLIENT_ID`
|
||||
is the marker and is **reserved** — `/oauth/device/authorize` refuses a
|
||||
request naming it, so a device client cannot self-declare into this class.
|
||||
- **No rotation** — login grants return the SAME refresh token on every
|
||||
renewal. Rotation + reuse detection is right for a browser-adjacent
|
||||
third-party client, but for an unattended host it turns any ambiguous
|
||||
network failure (lost response, crash between the server committing and
|
||||
the client persisting) into a permanently revoked grant. Only the
|
||||
short-lived access token is renewed; revocation and the absolute lifetime
|
||||
cap still bound exposure. Device grants keep rotating.
|
||||
- **Renewal** — `omnigent.cli_auth.refresh_stored_token` POSTs
|
||||
`grant_type=refresh_token`, persists the result, and returns the
|
||||
fresh access token. The runner/host auth-token factory
|
||||
calls it when the stored token lapses, and the host tunnel rebuilds
|
||||
headers through that factory on every reconnect — so an unattended
|
||||
host renews itself for the grant's lifetime. Refreshes on one machine
|
||||
are serialized by an advisory file lock with a re-check after acquire,
|
||||
so a losing racer picks up the winner's rotated pair instead of
|
||||
replaying the stale token (which reuse detection would punish by
|
||||
revoking the grant).
|
||||
- **One grant per host replica (recommended)** — since login grants do
|
||||
not rotate, N replicas sharing one token file no longer revoke each
|
||||
other. A per-replica login is still preferred so a single host can be
|
||||
revoked without cutting off the rest.
|
||||
- **Lifetime** — the absolute grant lifetime stays 30 days by default;
|
||||
`OMNIGENT_GRANT_MAX_LIFETIME_DAYS` lets an operator extend it
|
||||
deliberately for long-lived unattended hosts. Invalid values fall back
|
||||
to the default (never fail open to unbounded).
|
||||
- **Client-secret interaction** — `OMNIGENT_DEVICE_CLIENT_SECRET` gates
|
||||
the endpoints that mint from an ephemeral code (device authorize + the
|
||||
`device_code` exchange). Refresh and revoke are NOT gated: the presented
|
||||
refresh/access token is itself the credential, and a CLI renewing its own
|
||||
login has no way to carry that secret (gating it made every automatic
|
||||
refresh 401 in a Slack-serving deployment).
|
||||
- **Housekeeping** — `/oauth/token` opportunistically purges expired and
|
||||
aged-out grants. The device flow purges on `authorize`, which a
|
||||
standalone token-router mount does not have, so login-grant rows would
|
||||
otherwise accumulate one per login.
|
||||
|
||||
### Known limitation
|
||||
|
||||
General CLI commands (`omnigent usage`, session commands, direct
|
||||
remote-URL chat) still read the stored token without attempting a refresh —
|
||||
only the host/runner auth-token factory renews today. An expired login with
|
||||
valid refresh material therefore leaves those commands unauthenticated
|
||||
until something on the host path renews the shared file. Wiring the
|
||||
remaining CLI entrypoints is follow-up work.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Release feature flags
|
||||
|
||||
Omnigent release features are deployment-wide, temporary rollout switches. They
|
||||
are not authorization controls or user preferences.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the comma-separated `OMNIGENT_FEATURES` environment variable and restart or
|
||||
redeploy the server:
|
||||
|
||||
```bash
|
||||
OMNIGENT_FEATURES=usage_page,harness_install
|
||||
```
|
||||
|
||||
Unset or empty means every release feature is off. Unknown names fail startup.
|
||||
The former `OMNIGENT_HARNESS_INSTALL_ENABLED` switch is rejected with a
|
||||
migration hint; use `OMNIGENT_FEATURES=harness_install` instead. The server resolves the set once at startup and publishes frontend-visible
|
||||
values in `GET /v1/info` under `features`. Users must reload the web app after a
|
||||
flag change because server capabilities are cached at page boot.
|
||||
|
||||
`omnigent/server/feature_flags.py` is the source of truth for known keys and
|
||||
lifecycle metadata.
|
||||
|
||||
## Inventory
|
||||
|
||||
| Key | Default | Owner | Review by | Purpose |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `usage_page` | Off | Web | 0.11.0 | Exposes the web Usage route, sidebar navigation, timeline, and cost breakdown details. The existing `GET /v1/usage` CLI API remains available while off. |
|
||||
| `harness_install` | Off | Onboarding | 0.11.0 | Allows the web UI to install or configure supported harnesses on a connected host. |
|
||||
|
||||
At the review release, each flag must be removed by making the feature
|
||||
unconditional, removing the feature, or moving a genuinely permanent operator
|
||||
policy into normal server configuration.
|
||||
|
||||
## Rollout and rollback
|
||||
|
||||
1. Deploy an immutable image with the feature absent from `OMNIGENT_FEATURES`.
|
||||
2. Enable it on one deployment, consistently across all replicas.
|
||||
3. Verify `GET /v1/info`, then reload and exercise the gated UI.
|
||||
4. Expand by deployment cohort.
|
||||
5. Roll back by removing the key and redeploying the same image.
|
||||
@@ -62,12 +62,36 @@ see *Network* below).
|
||||
| `add_comment` | `POST /v1/sessions/{id}/comments` — create a review comment | write path |
|
||||
| `list_projects` | `GET /v1/sessions/projects` — sidebar project list (dual-read union) | project count |
|
||||
| `list_project_sessions` | `GET /v1/sessions?project=` — a project folder's sessions (dual-read filter) | sessions/project |
|
||||
| `forward_native_deltas_per_event` | `POST /v1/sessions/{id}/events` × 24 — a native forwarder mirroring one poll's streamed text one request at a time | round-trip count |
|
||||
| `forward_native_deltas_batched` | `POST /v1/sessions/{id}/events/batch` — the same poll's text in one request | round-trip count |
|
||||
|
||||
The two `forward_native_deltas_*` journeys are a matched pair: same work, 24
|
||||
round trips versus 1. Run them with `--network-delay-ms` to price the wire,
|
||||
which is what a user in another region actually pays — at `--network-delay-ms
|
||||
100` the per-event journey takes ~2.5s per poll against ~0.14s batched, and a
|
||||
turn is many polls. They are also the regression guard: if the forwarder ever
|
||||
fans back out into per-event posts, the batched journey's `HTTP/op` climbs off 1.
|
||||
|
||||
Read journeys target a **pre-seeded** session when the DB has a corpus; against
|
||||
an empty DB they self-seed a small fallback session over HTTP (the
|
||||
`external_conversation_item` event — appends items without starting a task), so
|
||||
they still work with no runner or LLM.
|
||||
|
||||
### Hook spawn (no server)
|
||||
|
||||
| Journey | Operation timed |
|
||||
| --- | --- |
|
||||
| `native_hook_spawn` | Spawn the per-chunk `MessageDisplay` hook exactly as Claude Code does — isolated interpreter, module entrypoint, JSON payload on stdin |
|
||||
|
||||
Claude Code **blocks its TUI** on command hooks, so one hook subprocess's
|
||||
lifetime is user-visible streaming latency, and the same interpreter+import
|
||||
cost fronts every statusline refresh and per-tool-call policy hook. The
|
||||
journey needs no server or runner; registering it here rides hook spawn cost
|
||||
on the same nightly/release regression comparison as everything else
|
||||
(`omnigent/__init__` re-exports lazily so this stays ~interpreter-sized). The
|
||||
import-graph side of the guarantee is pinned deterministically by
|
||||
`tests/test_claude_native_message_display_hook.py`.
|
||||
|
||||
### Full-turn (runner + mock LLM)
|
||||
|
||||
These drive a real agent turn end-to-end — `POST …/events` → server → **runner**
|
||||
|
||||
@@ -44,7 +44,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, cast
|
||||
@@ -97,6 +104,9 @@ class Journey:
|
||||
per op, so 100+ iterations would blow the CI time budget; they cap at a
|
||||
few samples per run and lean on ``--runs`` for repeats. ``None`` (HTTP
|
||||
journeys) means no cap.
|
||||
:param skip_warmup: When ``True``, the warmup phase is skipped regardless
|
||||
of ``--warmup``. Useful for expensive journeys where even a single
|
||||
warmup iteration would waste significant time.
|
||||
:param description: Human-readable one-liner for ``--list``.
|
||||
"""
|
||||
|
||||
@@ -110,6 +120,7 @@ class Journey:
|
||||
needs_runner: bool = False
|
||||
needs_host: bool = False
|
||||
max_iterations: int | None = None
|
||||
skip_warmup: bool = False
|
||||
description: str = ""
|
||||
|
||||
async def run_setup(self, env: BenchEnvironment) -> JourneyContext:
|
||||
@@ -131,10 +142,13 @@ def _failure_reason(exc: Exception) -> str:
|
||||
"""Classify an exception into a stable failure-breakdown label.
|
||||
|
||||
HTTP status errors key off their status code (``"HTTP 500"``) so the same
|
||||
server error groups across ops; anything else keys off its class name.
|
||||
server error groups across ops; RuntimeErrors include the message so CI
|
||||
failure breakdowns show the actual cause; anything else keys off class name.
|
||||
"""
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return f"HTTP {exc.response.status_code}"
|
||||
if isinstance(exc, RuntimeError):
|
||||
return f"RuntimeError: {exc}"
|
||||
return exc.__class__.__name__
|
||||
|
||||
|
||||
@@ -235,7 +249,8 @@ async def run_latency(
|
||||
except Exception as exc: # noqa: BLE001 — a setup failure is a recorded data point
|
||||
return _setup_failed_result(exc)
|
||||
try:
|
||||
for _ in range(warmup):
|
||||
effective_warmup = 0 if journey.skip_warmup else warmup
|
||||
for _ in range(effective_warmup):
|
||||
with contextlib.suppress(Exception): # warmup errors are non-fatal
|
||||
await journey.run_prepare(env, ctx)
|
||||
await journey.measure(env, ctx)
|
||||
@@ -681,6 +696,12 @@ async def _measure_read_runner_file(env: BenchEnvironment, ctx: JourneyContext)
|
||||
|
||||
# ── policy evaluate ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _bench_policy_allow(_event: dict) -> dict: # type: ignore[type-arg]
|
||||
"""Benchmark policy function: always ALLOW. Self-contained in this module."""
|
||||
return {"result": "allow"}
|
||||
|
||||
|
||||
_POLICY_EVALUATE_PAYLOAD = {
|
||||
"event": {
|
||||
"type": "PHASE_TOOL_CALL",
|
||||
@@ -707,15 +728,24 @@ async def _setup_policy_evaluate_session(env: BenchEnvironment) -> str:
|
||||
|
||||
import yaml
|
||||
|
||||
# Build a bundle like BenchEnvironment._agent_bundle but with a policy
|
||||
# declared so any_policies_apply is true and the full engine runs.
|
||||
executor: dict[str, object] = {
|
||||
"type": "omnigent",
|
||||
"model": env.model,
|
||||
"config": {"harness": env.harness},
|
||||
}
|
||||
config: dict[str, object] = {
|
||||
"spec_version": 1,
|
||||
"name": "bench-policy-agent",
|
||||
"prompt": "benchmark",
|
||||
"executor": executor,
|
||||
"guardrails": {
|
||||
"policies": {
|
||||
"allow_all": {
|
||||
"type": "function",
|
||||
"on": ["tool_call"],
|
||||
"function": "tests.runtime.policies.conftest._always_allow",
|
||||
"function": "dev.benchmarks.omnigent.journeys._bench_policy_allow",
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -728,16 +758,17 @@ async def _setup_policy_evaluate_session(env: BenchEnvironment) -> str:
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
bundle = buf.getvalue()
|
||||
|
||||
# Register the agent + create a session in one call via the bundle upload
|
||||
# path (``POST /v1/sessions`` multipart). ``/v1/agents`` is GET-only.
|
||||
resp = await env.client.post(
|
||||
"/v1/agents",
|
||||
"/v1/sessions",
|
||||
data={"metadata": "{}"},
|
||||
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
agent_id = resp.json()["id"]
|
||||
|
||||
session_resp = await env.client.post("/v1/sessions", json={"agent_id": agent_id})
|
||||
session_resp.raise_for_status()
|
||||
session_id = session_resp.json()["id"]
|
||||
body = resp.json()
|
||||
# Bundle upload returns ``session_id`` (not ``id``).
|
||||
session_id = body.get("session_id") or body["id"]
|
||||
|
||||
# Warm the spec + policy caches — the measured iteration is steady-state.
|
||||
for _ in range(2):
|
||||
@@ -759,6 +790,210 @@ async def _measure_policy_evaluate(env: BenchEnvironment, ctx: JourneyContext) -
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
# ── native-forwarder event mirroring (per-event vs batched) ──────────────────
|
||||
|
||||
# Chunks in one simulated poll's worth of streamed assistant text. A native
|
||||
# harness produces this many in well under a second, and the forwarder used to
|
||||
# spend one round trip on each — which is why a far-from-server client's live
|
||||
# view fell behind. Pair these two journeys with ``--network-delay-ms`` to see
|
||||
# the round-trip cost directly: the per-event journey pays it 24 times, the
|
||||
# batched one once.
|
||||
_FORWARD_DELTA_COUNT = 24
|
||||
|
||||
# One op is 24 requests for the per-event journey, so the default 100 iterations
|
||||
# would let this pair dominate the CI leg (and add tail noise to the journeys
|
||||
# after it). The pair exists to price round trips, which a handful of samples
|
||||
# establishes.
|
||||
_FORWARD_DELTA_MAX_ITERATIONS = 20
|
||||
|
||||
|
||||
def _forward_delta_events(message_id: str) -> list[dict[str, object]]:
|
||||
"""
|
||||
Build one poll's worth of streamed-text events.
|
||||
|
||||
:param message_id: Assistant message the chunks belong to, so the
|
||||
server scopes them to one in-flight buffer.
|
||||
:returns: ``external_output_text_delta`` event bodies, in order.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"type": "external_output_text_delta",
|
||||
"data": {
|
||||
"delta": f"chunk-{index} ",
|
||||
"message_id": message_id,
|
||||
"index": index,
|
||||
"final": index == _FORWARD_DELTA_COUNT - 1,
|
||||
},
|
||||
}
|
||||
for index in range(_FORWARD_DELTA_COUNT)
|
||||
]
|
||||
|
||||
|
||||
async def _measure_forward_deltas_per_event(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Mirror a poll's chunks one request at a time (the pre-batching path)."""
|
||||
session_id = cast(str, ctx) # _setup_target_session
|
||||
assert env.client is not None
|
||||
for event in _forward_delta_events(f"bench-{uuid.uuid4().hex}"):
|
||||
resp = await env.client.post(f"/v1/sessions/{session_id}/events", json=event)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _measure_forward_deltas_batched(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Mirror the same chunks in one request."""
|
||||
session_id = cast(str, ctx) # _setup_target_session
|
||||
assert env.client is not None
|
||||
resp = await env.client.post(
|
||||
f"/v1/sessions/{session_id}/events/batch",
|
||||
json={
|
||||
"events": _forward_delta_events(f"bench-{uuid.uuid4().hex}"),
|
||||
"on_error": "continue",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
# ── CLI startup (omnigent polly against the local bench server) ──────────────
|
||||
|
||||
# Signal that the REPL is ready — the last spinner message before the prompt.
|
||||
# polly (omnigent run) emits this just before the agent REPL appears.
|
||||
_CLI_STARTUP_READY_SIGNAL = "Launching your agent"
|
||||
|
||||
# Per-attempt timeout. With the bench host daemon pre-running (needs_host=True),
|
||||
# polly reuses it; remaining work is session + runner connect ~5-20s on CI.
|
||||
_CLI_STARTUP_TIMEOUT_S = 60
|
||||
|
||||
# ~5s per attempt; cap so a large --iterations stays in budget.
|
||||
_CLI_STARTUP_MAX_ITERATIONS = 3
|
||||
|
||||
|
||||
async def _prepare_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> None:
|
||||
"""Stop stale daemons before each timed cli_startup iteration.
|
||||
|
||||
A leftover host daemon from the previous iteration causes the next
|
||||
``omnigent polly`` to fail with "runner tunnel rejection (HTTP 401)"
|
||||
or "host is on another replica". Runs outside the latency timer.
|
||||
"""
|
||||
del env
|
||||
omnigent_bin = os.environ.get("OMNIGENT_BIN") or shutil.which("omnigent")
|
||||
if omnigent_bin is None:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[omnigent_bin, "stop"],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
async def _measure_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> None:
|
||||
"""Time ``omnigent polly --server`` from invocation to REPL ready.
|
||||
|
||||
Spawns ``omnigent polly --server <local>`` via pexpect and times until
|
||||
``"Launching your agent…"`` appears — the last spinner message before the
|
||||
agent REPL. Using polly (the bundled openai-agents harness) avoids any
|
||||
external binary dependency while exercising the same startup path as
|
||||
``omnigent claude``: daemon start, session create, runner launch, and
|
||||
runner connect.
|
||||
|
||||
Requires ``pexpect``. No external LLM binary needed.
|
||||
|
||||
:param env: Benchmark environment — ``env.base_url`` is the local server URL.
|
||||
:param _ctx: Unused (no setup context).
|
||||
:raises RuntimeError: On timeout or process exit before the ready signal.
|
||||
"""
|
||||
try:
|
||||
import pexpect
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"pexpect is required for cli_startup. Install with: pip install pexpect"
|
||||
) from exc
|
||||
|
||||
omnigent_bin = os.environ.get("OMNIGENT_BIN") or shutil.which("omnigent")
|
||||
if omnigent_bin is None:
|
||||
raise RuntimeError("omnigent binary not found. Set OMNIGENT_BIN or add omnigent to PATH.")
|
||||
|
||||
child = pexpect.spawn(
|
||||
omnigent_bin,
|
||||
args=["polly", "--server", env.base_url],
|
||||
timeout=_CLI_STARTUP_TIMEOUT_S,
|
||||
encoding="utf-8",
|
||||
codec_errors="ignore",
|
||||
env=dict(os.environ),
|
||||
)
|
||||
try:
|
||||
idx = child.expect([pexpect.TIMEOUT, pexpect.EOF, _CLI_STARTUP_READY_SIGNAL])
|
||||
if idx == 0:
|
||||
raise RuntimeError(
|
||||
f"Timed out after {_CLI_STARTUP_TIMEOUT_S}s waiting for "
|
||||
f"{_CLI_STARTUP_READY_SIGNAL!r}"
|
||||
)
|
||||
if idx == 1:
|
||||
output = (child.before or "").strip()
|
||||
raise RuntimeError(
|
||||
f"Process exited before {_CLI_STARTUP_READY_SIGNAL!r}. "
|
||||
f"Last output: {output[-200:]!r}"
|
||||
)
|
||||
child.sendline("/exit")
|
||||
child.expect([pexpect.EOF, pexpect.TIMEOUT], timeout=10)
|
||||
finally:
|
||||
if child.isalive():
|
||||
child.terminate(force=True)
|
||||
|
||||
|
||||
# ── native hook spawn (no server involved) ───────────────────
|
||||
|
||||
# Claude Code blocks its TUI on command hooks, so one hook subprocess's whole
|
||||
# lifetime is user-visible latency: the MessageDisplay hook runs once per
|
||||
# streamed text chunk, and the same interpreter+import cost fronts every
|
||||
# statusline refresh and per-tool-call policy hook. Spawn the per-chunk hook
|
||||
# exactly as Claude Code does — isolated interpreter, module entrypoint, JSON
|
||||
# payload on stdin — and time the full process lifetime. The import-graph side
|
||||
# of this guarantee is pinned by tests/test_claude_native_message_display_hook.
|
||||
_HOOK_SPAWN_PAYLOAD = json.dumps(
|
||||
{
|
||||
"hook_event_name": "MessageDisplay",
|
||||
"message_id": "bench-message",
|
||||
"index": 0,
|
||||
"final": False,
|
||||
"delta": "benchmark chunk",
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
async def _setup_hook_spawn(env: BenchEnvironment) -> JourneyContext:
|
||||
"""A throwaway bridge dir for the hook's appended deltas file."""
|
||||
del env
|
||||
return tempfile.mkdtemp(prefix="omnigent-bench-hook-")
|
||||
|
||||
|
||||
async def _measure_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Spawn the MessageDisplay hook once, as Claude Code does, and wait."""
|
||||
del env
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_message_display_hook",
|
||||
"--bridge-dir",
|
||||
str(ctx),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate(_HOOK_SPAWN_PAYLOAD)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"hook exited {proc.returncode}: {stderr.decode('utf-8', 'replace')[:200]}"
|
||||
)
|
||||
|
||||
|
||||
async def _teardown_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Remove the throwaway bridge dir."""
|
||||
del env
|
||||
shutil.rmtree(str(ctx), ignore_errors=True)
|
||||
|
||||
|
||||
# ── registry ─────────────────────────────────────────────────
|
||||
|
||||
ALL_JOURNEYS: dict[str, Journey] = {
|
||||
@@ -843,6 +1078,31 @@ ALL_JOURNEYS: dict[str, Journey] = {
|
||||
description="POST /v1/sessions/{id}/policies/evaluate — PreToolUse hook "
|
||||
"(single tree scan, preloaded conversation row, caches warm).",
|
||||
),
|
||||
Journey(
|
||||
name="forward_native_deltas_per_event",
|
||||
kind="latency",
|
||||
measure=_measure_forward_deltas_per_event,
|
||||
setup=_setup_target_session,
|
||||
concurrency_safe=True,
|
||||
max_iterations=_FORWARD_DELTA_MAX_ITERATIONS,
|
||||
description=(
|
||||
f"POST /v1/sessions/{{id}}/events × {_FORWARD_DELTA_COUNT} — a native "
|
||||
"forwarder mirroring one poll's streamed text one request at a time."
|
||||
),
|
||||
),
|
||||
Journey(
|
||||
name="forward_native_deltas_batched",
|
||||
kind="latency",
|
||||
measure=_measure_forward_deltas_batched,
|
||||
setup=_setup_target_session,
|
||||
concurrency_safe=True,
|
||||
max_iterations=_FORWARD_DELTA_MAX_ITERATIONS,
|
||||
description=(
|
||||
"POST /v1/sessions/{id}/events/batch — the same poll's streamed text "
|
||||
"in one request. Compare against forward_native_deltas_per_event "
|
||||
"under --network-delay-ms."
|
||||
),
|
||||
),
|
||||
# Runner (full-turn) journeys — with_runner=True, openai-agents, mock LLM.
|
||||
Journey(
|
||||
name="session_cold_start",
|
||||
@@ -904,9 +1164,33 @@ ALL_JOURNEYS: dict[str, Journey] = {
|
||||
max_iterations=_RUNNER_FS_MAX_ITERATIONS,
|
||||
description="GET .../environments/default/filesystem/{path} — runner file read proxy.",
|
||||
),
|
||||
Journey(
|
||||
name="native_hook_spawn",
|
||||
kind="latency",
|
||||
measure=_measure_hook_spawn,
|
||||
setup=_setup_hook_spawn,
|
||||
teardown=_teardown_hook_spawn,
|
||||
description="Spawn the per-chunk MessageDisplay hook exactly as Claude Code does.",
|
||||
),
|
||||
Journey(
|
||||
name="cli_startup",
|
||||
kind="latency",
|
||||
measure=_measure_cli_startup,
|
||||
prepare=_prepare_cli_startup,
|
||||
max_iterations=_CLI_STARTUP_MAX_ITERATIONS,
|
||||
skip_warmup=True,
|
||||
description=(
|
||||
"Spawn `omnigent polly --server` and time invocation → REPL ready "
|
||||
"(daemon + session + runner connect). No LLM call needed. "
|
||||
"Requires pexpect."
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
# Registry alias — kept for callers that enumerate opt-in journeys explicitly.
|
||||
OPT_IN_JOURNEYS: dict[str, Journey] = {}
|
||||
|
||||
|
||||
def resolve_journeys(names: list[str] | None) -> list[Journey]:
|
||||
"""Resolve requested journey *names* (or all when ``None``/empty).
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Render ``run.py`` JSON reports as GitHub-flavoured markdown result matrices.
|
||||
|
||||
Where ``compare.py`` renders a baseline-vs-candidate regression table, this
|
||||
renders the absolute numbers of one or more standalone reports — the
|
||||
journey × metric matrix a CI job appends to ``$GITHUB_STEP_SUMMARY``. Given
|
||||
several reports (e.g. the nightly's sqlite / postgres / mysql legs) it also
|
||||
leads with a cross-report P50 matrix so the backends can be compared side by
|
||||
side.
|
||||
|
||||
Usage::
|
||||
|
||||
report_markdown.py REPORT.json [REPORT.json ...] [--title TEXT]
|
||||
|
||||
Prints markdown to stdout. Report labels come from each report's
|
||||
``config.backend``; when two reports share a backend the filename stem is
|
||||
appended to keep the columns distinguishable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Keep table cells one-line and skimmable: a skip reason is an exception
|
||||
# rendering, which can run long and embed newlines that would break the row.
|
||||
_MAX_NOTE_LEN = 100
|
||||
|
||||
|
||||
def _fmt_ms(value: object) -> str:
|
||||
"""Format a millisecond metric, or ``—`` when it is absent."""
|
||||
return f"{value:.1f}" if isinstance(value, (int, float)) else "—"
|
||||
|
||||
|
||||
def _fmt_rps(value: object) -> str:
|
||||
"""Format a requests-per-second metric, or ``—`` when it is absent."""
|
||||
return f"{value:.0f}" if isinstance(value, (int, float)) else "—"
|
||||
|
||||
|
||||
def _one_line(text: str) -> str:
|
||||
"""Collapse *text* onto one bounded line so it can live in a table cell."""
|
||||
flattened = " ".join(str(text).split())
|
||||
if len(flattened) > _MAX_NOTE_LEN:
|
||||
return flattened[: _MAX_NOTE_LEN - 1] + "…"
|
||||
return flattened
|
||||
|
||||
|
||||
def _journey_note(block: dict) -> str:
|
||||
"""The Notes cell for one journey block: skip reason / failure marker."""
|
||||
if block.get("skipped"):
|
||||
return _one_line(f"⚠️ skipped — {block.get('error', 'unknown error')}")
|
||||
summary = block.get("summary") or {}
|
||||
if summary and not summary.get("runs_ok"):
|
||||
return "❌ every run failed"
|
||||
return ""
|
||||
|
||||
|
||||
def _runs_cell(block: dict) -> str:
|
||||
"""The Runs cell: ``ok/total``, or ``—`` for a skipped journey."""
|
||||
summary = block.get("summary") or {}
|
||||
total = summary.get("runs_total")
|
||||
if not isinstance(total, int):
|
||||
return "—"
|
||||
return f"{summary.get('runs_ok', 0)}/{total}"
|
||||
|
||||
|
||||
def _caption(report: dict) -> str:
|
||||
"""One italic line of run context under a section heading."""
|
||||
config = report.get("config") or {}
|
||||
parts: list[str] = []
|
||||
iterations = config.get("iterations")
|
||||
runs = config.get("runs")
|
||||
if iterations is not None and runs is not None:
|
||||
parts.append(f"{iterations} iterations × {runs} runs")
|
||||
warmup = config.get("warmup")
|
||||
if warmup is not None:
|
||||
parts.append(f"warmup {warmup}")
|
||||
harness = report.get("harness")
|
||||
if harness:
|
||||
parts.append(str(harness))
|
||||
sha = report.get("git_sha")
|
||||
if sha:
|
||||
parts.append(f"`{str(sha)[:8]}`")
|
||||
return f"_{' · '.join(parts)}_" if parts else ""
|
||||
|
||||
|
||||
def _report_section(label: str, report: dict) -> list[str]:
|
||||
"""Markdown lines for one report: heading, caption, journey × metric table."""
|
||||
lines = [f"### {label}", ""]
|
||||
caption = _caption(report)
|
||||
if caption:
|
||||
lines.extend([caption, ""])
|
||||
lines.extend(
|
||||
[
|
||||
"| Journey | Mean ms | P50 ms | P95 ms | P99 ms | Req/s | Runs | Notes |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
||||
]
|
||||
)
|
||||
for name, block in (report.get("journeys") or {}).items():
|
||||
summary = block.get("summary") or {}
|
||||
lines.append(
|
||||
f"| {name} "
|
||||
f"| {_fmt_ms(summary.get('avg_mean_ms'))} "
|
||||
f"| {_fmt_ms(summary.get('avg_p50_ms'))} "
|
||||
f"| {_fmt_ms(summary.get('avg_p95_ms'))} "
|
||||
f"| {_fmt_ms(summary.get('avg_p99_ms'))} "
|
||||
f"| {_fmt_rps(summary.get('avg_rps'))} "
|
||||
f"| {_runs_cell(block)} "
|
||||
f"| {_journey_note(block)} |"
|
||||
)
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def _journey_order(labeled_reports: list[tuple[str, dict]]) -> list[str]:
|
||||
"""Union of journey names, keeping each report's insertion order."""
|
||||
ordered: list[str] = []
|
||||
for _, report in labeled_reports:
|
||||
for name in report.get("journeys") or {}:
|
||||
if name not in ordered:
|
||||
ordered.append(name)
|
||||
return ordered
|
||||
|
||||
|
||||
def _cross_matrix(labeled_reports: list[tuple[str, dict]]) -> list[str]:
|
||||
"""Journey × report P50 matrix so several reports compare side by side."""
|
||||
labels = [label for label, _ in labeled_reports]
|
||||
lines = [
|
||||
"### P50 across reports",
|
||||
"",
|
||||
"| Journey | " + " | ".join(f"{label} P50 ms" for label in labels) + " |",
|
||||
"| --- | " + " | ".join("---:" for _ in labels) + " |",
|
||||
]
|
||||
for name in _journey_order(labeled_reports):
|
||||
cells = []
|
||||
for _, report in labeled_reports:
|
||||
block = (report.get("journeys") or {}).get(name) or {}
|
||||
cells.append(_fmt_ms((block.get("summary") or {}).get("avg_p50_ms")))
|
||||
lines.append(f"| {name} | " + " | ".join(cells) + " |")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def build_markdown(labeled_reports: list[tuple[str, dict]], title: str | None = None) -> str:
|
||||
"""Render *labeled_reports* as one markdown document.
|
||||
|
||||
:param labeled_reports: ``(label, report)`` pairs, where *report* is a
|
||||
parsed ``run.py`` JSON report and *label* names it (e.g. its backend).
|
||||
:param title: Optional top-level heading, e.g. ``"Benchmark results"``.
|
||||
:returns: GitHub-flavoured markdown ending in a newline.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
if title:
|
||||
lines.extend([f"## {title}", ""])
|
||||
if len(labeled_reports) > 1:
|
||||
lines.extend(_cross_matrix(labeled_reports))
|
||||
for label, report in labeled_reports:
|
||||
lines.extend(_report_section(label, report))
|
||||
return "\n".join(lines).rstrip("\n") + "\n"
|
||||
|
||||
|
||||
def _label_for(path: Path, report: dict, seen: set[str]) -> str:
|
||||
"""Label a report by backend, disambiguating duplicates with the filename."""
|
||||
backend = (report.get("config") or {}).get("backend")
|
||||
label = str(backend) if backend else path.stem
|
||||
if label in seen:
|
||||
label = f"{label} ({path.stem})"
|
||||
seen.add(label)
|
||||
return label
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point: render the given report files to stdout."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("reports", nargs="+", type=Path, help="run.py JSON report file(s).")
|
||||
parser.add_argument("--title", default=None, help="Optional top-level heading.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
labeled: list[tuple[str, dict]] = []
|
||||
seen: set[str] = set()
|
||||
for path in args.reports:
|
||||
report = json.loads(path.read_text())
|
||||
labeled.append((_label_for(path, report, seen), report))
|
||||
sys.stdout.write(build_markdown(labeled, title=args.title))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -32,8 +32,7 @@ Runs from a **repo checkout** (it imports `dev.benchmarks` + `tests`), with the
|
||||
harness + bench deps:
|
||||
|
||||
```bash
|
||||
pip install -e '.[loadtest,dev,agents-sdk]'
|
||||
# or: uv sync --extra loadtest --extra dev --extra agents-sdk
|
||||
uv sync --extra loadtest --extra agents-sdk
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ Writes a timestamped result set:
|
||||
summary.md human-readable latency write-up (this tool)
|
||||
|
||||
Runs from a repo checkout only (imports ``dev.benchmarks`` + ``tests``), with
|
||||
the ``[loadtest,dev,agents-sdk]`` extras. Knobs: ``--users`` (N hosts),
|
||||
the ``loadtest`` and ``agents-sdk`` extras. Knobs: ``--users`` (N hosts),
|
||||
``--spawn-rate``, ``--run-time``, ``--sessions-per-user``, ``--turns-per-session``,
|
||||
``--reply-words``, ``--out-dir``.
|
||||
"""
|
||||
@@ -363,7 +363,7 @@ def main() -> int:
|
||||
if importlib.util.find_spec(mod) is None:
|
||||
sys.exit(
|
||||
f"{pkg} not importable under {sys.executable} — install the extras: "
|
||||
"pip install -e '.[loadtest,dev,agents-sdk]' (run from a repo checkout)."
|
||||
"uv sync --extra loadtest --extra agents-sdk (run from a repo checkout)."
|
||||
)
|
||||
out_dir = _resolve_out_dir(args.out_dir)
|
||||
return asyncio.run(_boot_and_run(args, out_dir))
|
||||
|
||||
@@ -50,7 +50,7 @@ Run it from anywhere inside the checkout — it walks up to the repo root
|
||||
|---|---|---|
|
||||
| server | `uv run omnigent --log-to-stderr server --host 127.0.0.1 --port <p> --database-uri … --artifact-location …` | Waited on via `GET /health`. |
|
||||
| host | `uv run omnigent --log-to-stderr host --server http://127.0.0.1:<p>` | Started once the server is healthy. |
|
||||
| vite | `pnpm run dev -- --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
|
||||
| vite | `pnpm run dev --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
|
||||
|
||||
Before Vite starts (and on a manual Vite restart), omnidev runs `pnpm install`
|
||||
in `web/` when needed — `node_modules/` is missing, or `package.json` /
|
||||
|
||||
@@ -143,6 +143,16 @@ mod tests {
|
||||
.find(|(k, _)| k == "OMNIGENT_DATABASE_URI")
|
||||
.map(|(_, v)| v.clone());
|
||||
assert_eq!(db, Some(pod.db_uri()));
|
||||
|
||||
let config_home = cmd
|
||||
.env
|
||||
.iter()
|
||||
.find(|(k, _)| k == "OMNIGENT_CONFIG_HOME")
|
||||
.map(|(_, v)| v.clone());
|
||||
assert_eq!(config_home, Some(pod.config_dir().display().to_string()));
|
||||
|
||||
assert!(cmd.env.iter().all(|(k, _)| k != "HOME"));
|
||||
assert!(cmd.env.iter().all(|(k, _)| !k.starts_with("XDG_")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -120,7 +120,7 @@ impl ProcSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// `pnpm run dev -- --host <host> --port <p> --strictPort`, from `web/`.
|
||||
/// `pnpm run dev --host <host> --port <p> --strictPort`, from `web/`.
|
||||
/// `OMNIGENT_URL` (in the pod env) points Vite's proxy at this pod's backend.
|
||||
pub fn vite(pod: &Pod) -> ProcSpec {
|
||||
if let Some(profile) = &pod.profile {
|
||||
@@ -128,10 +128,10 @@ impl ProcSpec {
|
||||
}
|
||||
ProcSpec {
|
||||
program: "pnpm".into(),
|
||||
// pnpm forwards script arguments directly; `--` would make Vite ignore the flags.
|
||||
args: vec![
|
||||
"run".into(),
|
||||
"dev".into(),
|
||||
"--".into(),
|
||||
"--host".into(),
|
||||
pod.vite_host.clone(),
|
||||
"--port".into(),
|
||||
@@ -151,7 +151,7 @@ mod tests {
|
||||
use crate::profile::{ProcessProfile, Profile};
|
||||
|
||||
#[test]
|
||||
fn vite_uses_configured_bind_host_but_backend_url_stays_loopback() {
|
||||
fn vite_forwards_configured_host_and_port_but_backend_url_stays_loopback() {
|
||||
let repo = tempdir();
|
||||
let pod_dir = tempdir();
|
||||
let pod = Pod::create(
|
||||
@@ -167,8 +167,18 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let vite = ProcSpec::vite(&pod);
|
||||
let host_flag = vite.args.iter().position(|arg| arg == "--host").unwrap();
|
||||
assert_eq!(vite.args[host_flag + 1], "0.0.0.0");
|
||||
assert_eq!(
|
||||
vite.args,
|
||||
[
|
||||
"run",
|
||||
"dev",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"19292",
|
||||
"--strictPort",
|
||||
]
|
||||
);
|
||||
assert_eq!(pod.server_url(), "http://127.0.0.1:19191");
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
## Global Constraints
|
||||
|
||||
- Python deps via `uv` only (never pip); JS/TS via `bun`. Latest stable deps.
|
||||
- Pre-commit gate (must pass): `uv run ruff check --fix && uv run ruff format && uv run mypy --strict . && uv run pytest`. Never disable a lint/type rule — fix the root cause.
|
||||
- Pre-commit gate (must pass): `uv sync --group dev && uv run --no-sync ruff check --fix && uv run --no-sync ruff format && uv run --no-sync pyrefly check && uv run --no-sync pytest`. Never disable a lint/type rule — fix the root cause.
|
||||
- agy pinned: `AGY_EXPECTED_VERSION=1.0.10` (Docker build fails on mismatch). All RPC shapes are version-sensitive.
|
||||
- connect-RPC: JSON (`Content-Type: application/json`), `verify=False`, every URL passes `_assert_loopback_url`. Reuse `antigravity_native_rpc.py` discovery (`discover_language_server_port` / `_candidate_agy_rpc_ports` / `_conversation_matches`).
|
||||
- Identity: `cascadeId == conversationId == brain-dir UUID` (no separate id lookup).
|
||||
@@ -80,7 +80,7 @@ def test_cancel_cascade_steps_true_on_200(monkeypatch):
|
||||
assert rpc.cancel_cascade_steps(52548, "conv-uuid") is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify FAIL** — `uv run pytest tests/test_antigravity_native_rpc.py -k "trajectory_steps or cancel_cascade" -v` → fail (undefined).
|
||||
- [ ] **Step 2: Run, verify FAIL** — `uv run --group test pytest tests/test_antigravity_native_rpc.py -k "trajectory_steps or cancel_cascade" -v` → fail (undefined).
|
||||
- [ ] **Step 3: Implement** `get_trajectory_steps` (POST `{"cascadeId": cascade_id}` to `GetCascadeTrajectorySteps`, parse `.get("steps", [])`) and `cancel_cascade_steps` (POST `{"cascadeId": cascade_id}` to `CancelCascadeSteps`, return `resp.status_code < 400`), both via `_sync_client` + `_assert_loopback_url`, mirroring `_conversation_matches`.
|
||||
- [ ] **Step 4: Run, verify PASS.**
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): RPC client — trajectory steps + cancel`).
|
||||
@@ -241,7 +241,7 @@ def test_handle_user_interaction_raises_on_500(monkeypatch):
|
||||
|
||||
- [ ] **Step 1:** Grep for `antigravity_native_forwarder` / `forwarded_steps` / `update_forwarded_steps` references; confirm only the reader path remains.
|
||||
- [ ] **Step 2:** Delete the forwarder module + its tests; remove the cursor fields/methods from the bridge; relocate the shared types.
|
||||
- [ ] **Step 3:** Run the full gate: `uv run ruff check --fix && uv run ruff format && uv run mypy --strict . && uv run pytest` (targeted antigravity suites + server).
|
||||
- [ ] **Step 3:** Run the full gate: `uv sync --group dev && uv run --no-sync ruff check --fix && uv run --no-sync ruff format && uv run --no-sync pyrefly check && uv run --no-sync pytest` (targeted antigravity suites + server).
|
||||
- [ ] **Step 4:** Commit (`refactor(antigravity-native): retire transcript forwarder + durable cursor (RPC reader supersedes)`).
|
||||
|
||||
---
|
||||
|
||||
@@ -293,11 +293,11 @@ This integration is a **separate package** (`omnigent-slack`) with heavy deps
|
||||
(slack_bolt, aiohttp) kept out of the core `omnigent` install. It resolves as an
|
||||
editable path dep of the root `omnigent` package via the `slack` extra (see
|
||||
`[tool.uv.sources]` in the root `pyproject.toml`), and shares the root's dev
|
||||
tooling (ruff, mypy, pytest) and config rather than carrying its own. Work on it
|
||||
tooling (Ruff, Pyrefly, pytest) and config rather than carrying its own. Work on it
|
||||
from the repo-root env:
|
||||
|
||||
```bash
|
||||
# From the repo root — add the slack extra to your existing extras:
|
||||
uv sync --extra slack # e.g. --extra all --extra dev --extra slack
|
||||
uv run omni integration slack
|
||||
# From the repo root — install the Slack capability and contributor tooling:
|
||||
uv sync --extra slack --group dev
|
||||
uv run --no-sync omni integration slack
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnigent-slack"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
description = "Slack Socket Mode bot that drives Omnigent sessions."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -180,7 +180,12 @@ def _register_error_handler(app: AsyncApp, logger: logging.Logger) -> None:
|
||||
|
||||
@app.error
|
||||
async def _on_error(error: Exception, body: dict[str, Any]) -> None:
|
||||
logger.exception("Unhandled Slack listener error; body_type=%s", body.get("type"))
|
||||
logger.error(
|
||||
"Unhandled Slack listener error: %s; body_type=%s",
|
||||
error,
|
||||
body.get("type"),
|
||||
exc_info=(type(error), error, error.__traceback__),
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(app: AsyncApp, service: SlackOmnigentService) -> None:
|
||||
|
||||
@@ -20,12 +20,37 @@ async def test_error_handler_logs_with_traceback(caplog: pytest.LogCaptureFixtur
|
||||
error_handler = app._async_middleware_error_handler
|
||||
assert error_handler is not None
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="test-app-error"):
|
||||
def raise_error() -> RuntimeError:
|
||||
try:
|
||||
raise RuntimeError("boom")
|
||||
except RuntimeError as exc:
|
||||
await error_handler.func(error=exc, body={"type": "event_callback"})
|
||||
return exc
|
||||
|
||||
assert any("Unhandled Slack listener error" in r.message for r in caplog.records)
|
||||
# The exception traceback is attached (logger.exception), not just the message.
|
||||
assert any(r.exc_info for r in caplog.records)
|
||||
error = raise_error()
|
||||
with caplog.at_level(logging.ERROR, logger="test-app-error"):
|
||||
await error_handler.func(error=error, body={"type": "event_callback"})
|
||||
|
||||
record = caplog.records[-1]
|
||||
assert "Unhandled Slack listener error: boom" in record.message
|
||||
assert record.exc_info == (RuntimeError, error, error.__traceback__)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handler_logs_exception_without_active_traceback(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An exception passed outside an ``except`` block still logs usefully."""
|
||||
app = AsyncApp(token="xoxb-dummy", signing_secret="x")
|
||||
logger = logging.getLogger("test-app-error-no-traceback")
|
||||
_register_error_handler(app, logger)
|
||||
error_handler = app._async_middleware_error_handler
|
||||
assert error_handler is not None
|
||||
|
||||
error = ValueError("created outside an except block")
|
||||
with caplog.at_level(logging.ERROR, logger="test-app-error-no-traceback"):
|
||||
await error_handler.func(error=error, body={"type": "event_callback"})
|
||||
|
||||
record = caplog.records[-1]
|
||||
assert "created outside an except block" in record.message
|
||||
assert record.exc_info == (ValueError, error, None)
|
||||
assert "NoneType: None" not in caplog.text
|
||||
|
||||
@@ -14,7 +14,7 @@ _check-uv:
|
||||
uv run --no-sync pre-commit --version
|
||||
|
||||
_ensure-uv:
|
||||
uv sync --extra all --extra dev
|
||||
uv sync --extra all --group dev
|
||||
|
||||
# --- iOS Ruby dependencies ---
|
||||
|
||||
@@ -87,11 +87,11 @@ electron-build: _ensure-web _ensure-electron
|
||||
|
||||
[group('lint')]
|
||||
lint: _ensure-uv
|
||||
uv run pre-commit run
|
||||
uv run --no-sync pre-commit run
|
||||
|
||||
[group('lint')]
|
||||
lint-all: _ensure-uv
|
||||
uv run pre-commit run --all-files
|
||||
uv run --no-sync pre-commit run --all-files
|
||||
|
||||
[group('lint')]
|
||||
typecheck-python: _ensure-uv
|
||||
@@ -108,4 +108,4 @@ lint-ts:
|
||||
|
||||
[group('lint')]
|
||||
normalize-locks: _ensure-uv
|
||||
uv run scripts/normalize_uv_lock_registry.py uv.lock || true
|
||||
uv run --no-sync scripts/normalize_uv_lock_registry.py uv.lock || true
|
||||
|
||||
+204
-65
@@ -29,72 +29,211 @@ from omnigent._env_compat import mirror_legacy_env as _mirror_legacy_env # noqa
|
||||
|
||||
_mirror_legacy_env()
|
||||
|
||||
from omnigent.inner.datamodel import ( # noqa: E402 — must follow md5 patch
|
||||
AgentDef,
|
||||
Connection,
|
||||
Credentials,
|
||||
History,
|
||||
Memory,
|
||||
MemoryConfig,
|
||||
Message,
|
||||
ParamDef,
|
||||
SessionState,
|
||||
)
|
||||
from omnigent.inner.executor import ( # noqa: E402 — must follow md5 patch
|
||||
Executor,
|
||||
ExecutorConfig,
|
||||
ExecutorError,
|
||||
ExecutorEvent,
|
||||
TextChunk,
|
||||
ToolCallComplete,
|
||||
ToolCallRequest,
|
||||
TurnCancelled,
|
||||
TurnComplete,
|
||||
)
|
||||
from omnigent.inner.policies import ( # noqa: E402 — must follow md5 patch
|
||||
FunctionPolicy,
|
||||
Policy,
|
||||
PolicyAction,
|
||||
PolicyResult,
|
||||
PromptPolicy,
|
||||
)
|
||||
from omnigent.inner.tools import ( # noqa: E402 — must follow md5 patch
|
||||
AgentTool,
|
||||
CancellableFunctionTool,
|
||||
FunctionTool,
|
||||
HandoffTool,
|
||||
InheritedTool,
|
||||
MCPTool,
|
||||
SkillTool,
|
||||
Tool,
|
||||
)
|
||||
# The public names below re-export lazily (PEP 562). This package init is on
|
||||
# the hot path of every ``python -m omnigent.<hook>`` subprocess Claude Code
|
||||
# spawns — once per streamed text chunk (the TUI blocks on the MessageDisplay
|
||||
# hook), per statusline refresh, and per tool call — and eagerly importing the
|
||||
# datamodel/executor graph here cost those spawns ~250 ms each. Names resolve
|
||||
# on first attribute access and are cached in module globals; the import-graph
|
||||
# guards live in tests/test_claude_native_message_display_hook.py and the
|
||||
# wall-clock trend in the ``native_hook_spawn`` benchmark journey.
|
||||
import importlib # noqa: E402
|
||||
from typing import TYPE_CHECKING, Any # noqa: E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor as ClaudeSDKExecutor
|
||||
from omnigent.inner.codex_executor import CodexExecutor as CodexExecutor
|
||||
from omnigent.inner.databricks_executor import DatabricksExecutor as DatabricksExecutor
|
||||
from omnigent.inner.datamodel import (
|
||||
AgentDef as AgentDef,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
Connection as Connection,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
Credentials as Credentials,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
History as History,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
Memory as Memory,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
MemoryConfig as MemoryConfig,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
Message as Message,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
ParamDef as ParamDef,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
SessionState as SessionState,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
Executor as Executor,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ExecutorConfig as ExecutorConfig,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ExecutorError as ExecutorError,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ExecutorEvent as ExecutorEvent,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
TextChunk as TextChunk,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ToolCallComplete as ToolCallComplete,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ToolCallRequest as ToolCallRequest,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
TurnCancelled as TurnCancelled,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
TurnComplete as TurnComplete,
|
||||
)
|
||||
from omnigent.inner.loader import load_agent_def as load_agent_def
|
||||
from omnigent.inner.open_responses_sdk import OpenResponsesExecutor as OpenResponsesExecutor
|
||||
from omnigent.inner.openai_agents_sdk_executor import (
|
||||
OpenAIAgentsSDKExecutor as OpenAIAgentsSDKExecutor,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
FunctionPolicy as FunctionPolicy,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
Policy as Policy,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
PolicyAction as PolicyAction,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
PolicyResult as PolicyResult,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
PromptPolicy as PromptPolicy,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
AgentTool as AgentTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
CancellableFunctionTool as CancellableFunctionTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
FunctionTool as FunctionTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
HandoffTool as HandoffTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
InheritedTool as InheritedTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
MCPTool as MCPTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
SkillTool as SkillTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
Tool as Tool,
|
||||
)
|
||||
from omnigent.inner.tracing import (
|
||||
disable_tracing as disable_tracing,
|
||||
)
|
||||
from omnigent.inner.tracing import (
|
||||
enable_tracing as enable_tracing,
|
||||
)
|
||||
from omnigent.inner.tracing import (
|
||||
is_tracing_enabled as is_tracing_enabled,
|
||||
)
|
||||
|
||||
# Public name → defining module for the always-present re-exports.
|
||||
_LAZY_EXPORTS = {
|
||||
"AgentDef": "omnigent.inner.datamodel",
|
||||
"Connection": "omnigent.inner.datamodel",
|
||||
"Credentials": "omnigent.inner.datamodel",
|
||||
"History": "omnigent.inner.datamodel",
|
||||
"Memory": "omnigent.inner.datamodel",
|
||||
"MemoryConfig": "omnigent.inner.datamodel",
|
||||
"Message": "omnigent.inner.datamodel",
|
||||
"ParamDef": "omnigent.inner.datamodel",
|
||||
"SessionState": "omnigent.inner.datamodel",
|
||||
"Executor": "omnigent.inner.executor",
|
||||
"ExecutorConfig": "omnigent.inner.executor",
|
||||
"ExecutorError": "omnigent.inner.executor",
|
||||
"ExecutorEvent": "omnigent.inner.executor",
|
||||
"TextChunk": "omnigent.inner.executor",
|
||||
"ToolCallComplete": "omnigent.inner.executor",
|
||||
"ToolCallRequest": "omnigent.inner.executor",
|
||||
"TurnCancelled": "omnigent.inner.executor",
|
||||
"TurnComplete": "omnigent.inner.executor",
|
||||
"FunctionPolicy": "omnigent.inner.policies",
|
||||
"Policy": "omnigent.inner.policies",
|
||||
"PolicyAction": "omnigent.inner.policies",
|
||||
"PolicyResult": "omnigent.inner.policies",
|
||||
"PromptPolicy": "omnigent.inner.policies",
|
||||
"AgentTool": "omnigent.inner.tools",
|
||||
"CancellableFunctionTool": "omnigent.inner.tools",
|
||||
"FunctionTool": "omnigent.inner.tools",
|
||||
"HandoffTool": "omnigent.inner.tools",
|
||||
"InheritedTool": "omnigent.inner.tools",
|
||||
"MCPTool": "omnigent.inner.tools",
|
||||
"SkillTool": "omnigent.inner.tools",
|
||||
"Tool": "omnigent.inner.tools",
|
||||
"load_agent_def": "omnigent.inner.loader",
|
||||
"disable_tracing": "omnigent.inner.tracing",
|
||||
"enable_tracing": "omnigent.inner.tracing",
|
||||
"is_tracing_enabled": "omnigent.inner.tracing",
|
||||
}
|
||||
|
||||
# Optional executors resolve to ``None`` when their extra's dependencies are
|
||||
# absent, matching the former eager try/except imports. Databricks also
|
||||
# tolerates ``OSError``: its SDK can raise one probing credentials at import.
|
||||
_OPTIONAL_EXPORTS = {
|
||||
"DatabricksExecutor": ("omnigent.inner.databricks_executor", (OSError, ImportError)),
|
||||
"ClaudeSDKExecutor": ("omnigent.inner.claude_sdk_executor", (ImportError,)),
|
||||
"OpenResponsesExecutor": ("omnigent.inner.open_responses_sdk", (ImportError,)),
|
||||
"OpenAIAgentsSDKExecutor": ("omnigent.inner.openai_agents_sdk_executor", (ImportError,)),
|
||||
"CodexExecutor": ("omnigent.inner.codex_executor", (ImportError,)),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Resolve a lazy re-export (or submodule) on first attribute access."""
|
||||
target = _LAZY_EXPORTS.get(name)
|
||||
if target is not None:
|
||||
value = getattr(importlib.import_module(target), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
optional = _OPTIONAL_EXPORTS.get(name)
|
||||
if optional is not None:
|
||||
target, absent_exceptions = optional
|
||||
try:
|
||||
value = getattr(importlib.import_module(target), name)
|
||||
except absent_exceptions:
|
||||
value = None
|
||||
globals()[name] = value
|
||||
return value
|
||||
# The eager imports used to bind ``inner`` (and other submodules touched
|
||||
# by them) as package attributes; keep ``omnigent.<submodule>`` access
|
||||
# working for consumers that only ran ``import omnigent``.
|
||||
try:
|
||||
return importlib.import_module(f"{__name__}.{name}")
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != f"{__name__}.{name}":
|
||||
raise
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""Include the lazy re-exports in ``dir(omnigent)``."""
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
try:
|
||||
from omnigent.inner.databricks_executor import DatabricksExecutor
|
||||
except (OSError, ImportError):
|
||||
DatabricksExecutor = None # type: ignore[misc,assignment]
|
||||
try:
|
||||
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
||||
except ImportError:
|
||||
ClaudeSDKExecutor = None # type: ignore[misc,assignment]
|
||||
try:
|
||||
from omnigent.inner.open_responses_sdk import OpenResponsesExecutor
|
||||
except ImportError:
|
||||
OpenResponsesExecutor = None # type: ignore[misc,assignment]
|
||||
try:
|
||||
from omnigent.inner.openai_agents_sdk_executor import OpenAIAgentsSDKExecutor
|
||||
except ImportError:
|
||||
OpenAIAgentsSDKExecutor = None # type: ignore[misc,assignment]
|
||||
try:
|
||||
from omnigent.inner.codex_executor import CodexExecutor
|
||||
except ImportError:
|
||||
CodexExecutor = None # type: ignore[misc,assignment]
|
||||
from omnigent.inner.loader import load_agent_def # noqa: E402 — must follow md5 patch
|
||||
from omnigent.inner.tracing import ( # noqa: E402 — must follow md5 patch
|
||||
disable_tracing,
|
||||
enable_tracing,
|
||||
is_tracing_enabled,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentDef",
|
||||
|
||||
@@ -49,6 +49,19 @@ def record_post_failure(event_type: str, error: BaseException) -> None:
|
||||
_state["last_post_failure"] = (time.monotonic(), f"{event_type}: {error!r}")
|
||||
|
||||
|
||||
def record_transport_failure(detail: str) -> None:
|
||||
"""Record an already-formatted transport failure into the shared slot.
|
||||
|
||||
The SDK codex head has no forwarder POST path, but its subprocess still
|
||||
runs under the same idle-turn watchdog. When the model gateway rejects the
|
||||
CLI (e.g. a 401 read off the CLI's stderr), the turn emits no events and the
|
||||
watchdog would otherwise blame a generic "wedged LLM". Recording the parsed
|
||||
cause here lets the watchdog attribute the real failure, exactly as the
|
||||
native forwarder path does. *detail* is a ready-to-surface human string.
|
||||
"""
|
||||
_state["last_post_failure"] = (time.monotonic(), detail)
|
||||
|
||||
|
||||
def note_post_success() -> None:
|
||||
"""
|
||||
Clear the failure record after a POST that reached the server.
|
||||
|
||||
@@ -23,6 +23,7 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import weakref
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -146,6 +147,10 @@ def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
|
||||
- Connection-establishment / pool-acquire failures
|
||||
(:data:`_DELIVERY_SAFE_RETRY_ERRORS`): no bytes were sent → not
|
||||
delivered → safe to retry, so ``False``.
|
||||
- An unbound ``RequestError``: the failure occurred before httpx
|
||||
associated the exception with the outbound request (for example,
|
||||
an auth flow failed before yielding it). No bytes were sent → safe
|
||||
to retry, so ``False``.
|
||||
- Any other transport error (read/write timeout, read/write error,
|
||||
remote protocol error): the request was sent and we never saw a
|
||||
response, so the server may have processed it → ambiguous →
|
||||
@@ -159,6 +164,10 @@ def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
|
||||
return False
|
||||
if isinstance(exc, _DELIVERY_SAFE_RETRY_ERRORS):
|
||||
return False
|
||||
try:
|
||||
_ = exc.request
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -210,6 +219,199 @@ async def post_external_session_status(
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
# Batch event ingestion (``POST /v1/sessions/{id}/events/batch``). A forwarder
|
||||
# far from the server is otherwise capped at one event per round trip, so a
|
||||
# turn's live text trickles in for tens of seconds after the harness produced
|
||||
# it. Batching collapses a poll's worth of events into one request.
|
||||
#
|
||||
# Kept at half the server's ``MAX_SESSION_EVENTS_PER_BATCH`` (256) so a client
|
||||
# never trips the server's envelope validation, and so one request stays small
|
||||
# enough to retry cheaply. Longer runs are chunked.
|
||||
MAX_EVENTS_PER_BATCH = 128
|
||||
|
||||
# Clients whose server has no batch route. Latched on the first route-miss 404,
|
||||
# so talking to an older deployment costs one wasted request per client, not one
|
||||
# per event. Keyed by client (weakly) rather than by base URL: a forwarder holds
|
||||
# one client for its whole life, and nothing leaks into an unrelated client that
|
||||
# happens to share a server.
|
||||
_EVENTS_BATCH_UNSUPPORTED: weakref.WeakSet[httpx.AsyncClient] = weakref.WeakSet()
|
||||
|
||||
|
||||
def events_batch_supported(client: httpx.AsyncClient) -> bool:
|
||||
"""
|
||||
Report whether this server is still believed to serve the batch route.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:returns: ``False`` once a route-miss 404 latched the fallback.
|
||||
"""
|
||||
return client not in _EVENTS_BATCH_UNSUPPORTED
|
||||
|
||||
|
||||
def reset_events_batch_support(client: httpx.AsyncClient | None = None) -> None:
|
||||
"""
|
||||
Clear the batch-unsupported latch (tests; and after a server upgrade).
|
||||
|
||||
:param client: Client to clear, or ``None`` to clear every latch.
|
||||
:returns: None.
|
||||
"""
|
||||
if client is None:
|
||||
_EVENTS_BATCH_UNSUPPORTED.clear()
|
||||
return
|
||||
_EVENTS_BATCH_UNSUPPORTED.discard(client)
|
||||
|
||||
|
||||
def _is_route_miss(response: httpx.Response) -> bool:
|
||||
"""
|
||||
Distinguish "this server has no such route" from "no such session".
|
||||
|
||||
Omnigent's error handler shapes application 404s as
|
||||
``{"error": {...}}``; Starlette's route miss answers
|
||||
``{"detail": "Not Found"}``. Only the latter means the deployment
|
||||
predates the batch endpoint.
|
||||
|
||||
:param response: The 404 response to classify.
|
||||
:returns: ``True`` when the 404 came from routing, not the handler.
|
||||
"""
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return True
|
||||
return not (isinstance(body, dict) and "error" in body)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatchedEventOutcome:
|
||||
"""
|
||||
Per-event outcome of one batched session-event POST.
|
||||
|
||||
:param index: Position of the event in the submitted run.
|
||||
:param delivered: ``True`` when the server accepted the event (the
|
||||
status the equivalent single post would have returned was 2xx).
|
||||
:param status: That status, or ``None`` when the event was never
|
||||
attempted because an earlier failure stopped the batch.
|
||||
:param error: Failure reason reported by the server, when present.
|
||||
"""
|
||||
|
||||
index: int
|
||||
delivered: bool
|
||||
status: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _parse_batch_results(
|
||||
payload: object,
|
||||
*,
|
||||
total: int,
|
||||
offset: int,
|
||||
) -> list[BatchedEventOutcome]:
|
||||
"""
|
||||
Convert one batch response body into per-event outcomes.
|
||||
|
||||
Events the server never attempted (it stopped at a failure) are
|
||||
reported as not-delivered with a ``None`` status so callers keep them
|
||||
for the next attempt instead of treating silence as success.
|
||||
|
||||
:param payload: Decoded JSON body of the batch response.
|
||||
:param total: Number of events submitted in this request.
|
||||
:param offset: Index of this request's first event within the caller's
|
||||
full run, so outcomes are numbered in the caller's terms.
|
||||
:returns: One outcome per submitted event, in order.
|
||||
"""
|
||||
raw_results = payload.get("results") if isinstance(payload, dict) else None
|
||||
by_index: dict[int, BatchedEventOutcome] = {}
|
||||
if isinstance(raw_results, list):
|
||||
for position, entry in enumerate(raw_results):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
raw_index = entry.get("index")
|
||||
index = raw_index if isinstance(raw_index, int) else position
|
||||
raw_status = entry.get("status")
|
||||
status = raw_status if isinstance(raw_status, int) else None
|
||||
raw_error = entry.get("error")
|
||||
by_index[index] = BatchedEventOutcome(
|
||||
index=offset + index,
|
||||
delivered=status is not None and 200 <= status < 400,
|
||||
status=status,
|
||||
error=raw_error if isinstance(raw_error, str) else None,
|
||||
)
|
||||
return [
|
||||
by_index.get(index, BatchedEventOutcome(index=offset + index, delivered=False))
|
||||
for index in range(total)
|
||||
]
|
||||
|
||||
|
||||
async def post_session_events_batch(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
events: list[dict[str, object]],
|
||||
on_error: str = "stop",
|
||||
) -> list[BatchedEventOutcome] | None:
|
||||
"""
|
||||
POST a run of session events in as few round trips as possible.
|
||||
|
||||
Each event is the same ``{"type": ..., "data": ...}`` body a single
|
||||
``POST /v1/sessions/{id}/events`` would carry, and the server
|
||||
dispatches them in order through that same handler — so this changes
|
||||
only how many times the wire is crossed. That is the whole point: on
|
||||
a client far from the server, per-event posting caps the forwarder at
|
||||
a few events per second and its live view falls behind the harness.
|
||||
|
||||
Runs longer than :data:`MAX_EVENTS_PER_BATCH` are chunked. With
|
||||
``on_error="stop"`` a chunk that ends in a failure stops the run
|
||||
there, so a caller advancing a cursor over the delivered prefix
|
||||
behaves exactly as it did when posting one event at a time.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param events: Event bodies to dispatch, in order. Empty is a no-op.
|
||||
:param on_error: ``"stop"`` to leave the rest of a chunk unattempted
|
||||
after a failure (the default, matching a sequential caller), or
|
||||
``"continue"`` to attempt every event — for best-effort streams
|
||||
where dropping one chunk beats stalling the tail.
|
||||
:returns: One outcome per event, in order; or ``None`` when this
|
||||
server has no batch route and the caller should post singly.
|
||||
:raises httpx.HTTPError: On a transport failure or an
|
||||
envelope-level rejection. Delivery of the events in the failed
|
||||
chunk is then unknown, exactly as for a single post whose
|
||||
response was lost.
|
||||
"""
|
||||
if not events:
|
||||
return []
|
||||
if not events_batch_supported(client):
|
||||
return None
|
||||
outcomes: list[BatchedEventOutcome] = []
|
||||
for offset in range(0, len(events), MAX_EVENTS_PER_BATCH):
|
||||
chunk = events[offset : offset + MAX_EVENTS_PER_BATCH]
|
||||
response = await client.post(
|
||||
f"/v1/sessions/{session_id}/events/batch",
|
||||
json={"events": chunk, "on_error": on_error},
|
||||
)
|
||||
if response.status_code == 404 and _is_route_miss(response):
|
||||
_EVENTS_BATCH_UNSUPPORTED.add(client)
|
||||
_logger.debug(
|
||||
"Server has no session-events batch route; falling back to per-event posts for %s",
|
||||
client.base_url,
|
||||
)
|
||||
return None
|
||||
response.raise_for_status()
|
||||
note_native_post_success()
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise httpx.HTTPError(f"malformed session-events batch response: {exc}") from exc
|
||||
chunk_outcomes = _parse_batch_results(payload, total=len(chunk), offset=offset)
|
||||
outcomes.extend(chunk_outcomes)
|
||||
if on_error == "stop" and not all(outcome.delivered for outcome in chunk_outcomes):
|
||||
# The server stopped at a failure; everything after it in this
|
||||
# chunk was never attempted, and later chunks must not jump the
|
||||
# queue ahead of it.
|
||||
for index in range(offset + len(chunk), len(events)):
|
||||
outcomes.append(BatchedEventOutcome(index=index, delivered=False))
|
||||
break
|
||||
return outcomes
|
||||
|
||||
|
||||
async def post_session_event_with_retry(
|
||||
*,
|
||||
client: httpx.AsyncClient,
|
||||
|
||||
@@ -24,6 +24,14 @@ spawn-env builder. Rows own their auth and model selection (``OWN_AUTH``): no
|
||||
Omnigent credential or model override is wired, so a ``/model`` pick is
|
||||
rejected up front rather than silently dropped.
|
||||
|
||||
One consequence worth knowing before adding a row: the generic ACP spawn env is
|
||||
deny-by-default and a row has no ``env_passthrough`` of its own (only a
|
||||
user-configured ``acp:<slug>`` agent can declare one), so a row's CLI reaches the
|
||||
agent with the base environment only. A vendor that configures or authenticates
|
||||
*solely* from an environment variable therefore needs a user-configured agent
|
||||
rather than a row here; a vendor that reads stored credentials from disk (Devin,
|
||||
Grok's OAuth login) works as a row.
|
||||
|
||||
This module stays import-light (stdlib + :mod:`omnigent.harness_install_spec`)
|
||||
so the registry, onboarding, and runner layers can all read it without cycles.
|
||||
"""
|
||||
@@ -73,6 +81,24 @@ class AcpCliHarness:
|
||||
# Keyed by canonical harness id. Keep keys sorted; each row's registrations
|
||||
# derive from here (see the module docstring for the full list).
|
||||
ACP_CLI_HARNESSES: dict[str, AcpCliHarness] = {
|
||||
# Devin (Cognition's ``devin`` CLI) drives ``devin acp`` — its ACP stdio
|
||||
# server. Ships via a curl installer (not npm) and authenticates through its
|
||||
# own ``devin auth login``, which writes a credential file it reads back at
|
||||
# spawn; Omnigent stores nothing. The row runs Devin's account-default model:
|
||||
# a row carries no per-user model, and ``DEVIN_MODEL`` cannot reach the agent
|
||||
# (see the env note above), so pinning a model needs a user-configured
|
||||
# ``acp:<slug>`` agent whose command passes ``--model``.
|
||||
"devin": AcpCliHarness(
|
||||
install=HarnessInstallSpec(
|
||||
"Devin",
|
||||
"devin",
|
||||
None,
|
||||
login_args=("auth", "login"),
|
||||
install_hint="curl -fsSL https://cli.devin.ai/install.sh | bash",
|
||||
auth_hint="run `devin auth login` (Omnigent stores no Devin credential)",
|
||||
),
|
||||
args=("acp",),
|
||||
),
|
||||
# Grok Build (xAI's ``grok`` CLI) drives ``grok agent stdio``. Ships via a
|
||||
# curl installer (not npm) and authenticates through its own ``grok login``
|
||||
# (xAI OAuth, device-code capable) or ``XAI_API_KEY``; Omnigent stores no
|
||||
|
||||
@@ -800,10 +800,13 @@ async def _prepare_antigravity_terminal_via_daemon(
|
||||
_update_progress(startup_progress, "Creating Antigravity session...")
|
||||
bridge_id = _mint_agy_conversation_id()
|
||||
conversation_id = bridge_id
|
||||
session_id = await _create_antigravity_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=bridge_id,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_antigravity_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=bridge_id,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_progress(startup_progress, "Loading Antigravity session...")
|
||||
@@ -847,7 +850,8 @@ async def _prepare_antigravity_terminal_via_daemon(
|
||||
conversation_id = external if isinstance(external, str) and external else bridge_id
|
||||
resume = isinstance(external, str) and bool(external)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
+19
-14
@@ -1595,22 +1595,23 @@ async def _prepare_chat_session_via_daemon(
|
||||
)
|
||||
from omnigent.native_terminal import bind_session_runner
|
||||
|
||||
try:
|
||||
async def resolve_session() -> tuple[str, bool]:
|
||||
"""Fork, resume, or create the session to bind, and say if it is fresh.
|
||||
|
||||
:returns: The session id and whether it was created just now.
|
||||
:raises click.ClickException: If the server rejects the create/fork.
|
||||
"""
|
||||
async with OmnigentClient(base_url=base_url, headers=headers, auth=auth) as sdk:
|
||||
try:
|
||||
if fork_session_id is not None:
|
||||
fork_result = await sdk.sessions.fork(fork_session_id)
|
||||
session_id = fork_result["id"]
|
||||
fresh_session = False
|
||||
elif resume_conversation_id is not None:
|
||||
session_id = resume_conversation_id
|
||||
fresh_session = False
|
||||
else:
|
||||
created = await sdk.sessions.create(
|
||||
bundle, filename="agent.tar.gz", workspace=workspace
|
||||
)
|
||||
session_id = created.id
|
||||
fresh_session = True
|
||||
return fork_result["id"], False
|
||||
if resume_conversation_id is not None:
|
||||
return resume_conversation_id, False
|
||||
created = await sdk.sessions.create(
|
||||
bundle, filename="agent.tar.gz", workspace=workspace
|
||||
)
|
||||
return created.id, True
|
||||
except ClientOmnigentError as exc:
|
||||
# Any create/fork/resume rejection here is a server-side answer, not
|
||||
# a client bug worth a traceback: a wrong base URL that answers
|
||||
@@ -1621,6 +1622,7 @@ async def _prepare_chat_session_via_daemon(
|
||||
f"Could not start a session on {base_url}: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
# A separate raw httpx client for the host-runner protocol (the daemon
|
||||
# launch helpers operate on httpx, not the SDK), pinned to the host's replica.
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
@@ -1629,8 +1631,11 @@ async def _prepare_chat_session_via_daemon(
|
||||
) as client:
|
||||
if progress is not None:
|
||||
progress.update(STARTUP_PHASE_CONNECTING)
|
||||
await wait_for_host_online(
|
||||
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
|
||||
(session_id, fresh_session), _ = await asyncio.gather(
|
||||
resolve_session(),
|
||||
wait_for_host_online(
|
||||
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
|
||||
),
|
||||
)
|
||||
if progress is not None:
|
||||
progress.update(STARTUP_PHASE_LAUNCHING_AGENT)
|
||||
|
||||
@@ -55,6 +55,21 @@ CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
|
||||
#: takes — so it is not part of the vocabulary below.
|
||||
CUSTOM_MODEL_OPTION_NAME_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
|
||||
|
||||
#: BACK-COMPAT. Omnigent's picker-row id for the custom slot. Named for the
|
||||
#: model the slot first carried (Sonnet 5, which had no family alias of its
|
||||
#: own), but a Smart Routing launch pins ITS model there, so the id does not
|
||||
#: describe the contents — read the slot, never this name. Sessions persist
|
||||
#: it as a model override, so retiring it needs a migration; slated for
|
||||
#: removal in 0.10.0 along with the row, once the ``sonnet`` pin is Sonnet 5.
|
||||
LEGACY_CUSTOM_SLOT_ROW_ID = "sonnet_5"
|
||||
|
||||
#: BACK-COMPAT. Spellings the pre-0.10 substring test read as "this is the
|
||||
#: custom slot's model", kept because :func:`normalized_model_id` does not
|
||||
#: fold a vendor-prefixed ``anthropic/claude-sonnet-5`` onto the catalog id
|
||||
#: the slot holds. Consulted only after an exact match misses; retired with
|
||||
#: :data:`LEGACY_CUSTOM_SLOT_ROW_ID` in 0.10.0.
|
||||
LEGACY_CUSTOM_SLOT_SPELLINGS: tuple[str, ...] = ("sonnet-5", "sonnet_5")
|
||||
|
||||
#: Launch-env keys that define this session's model vocabulary.
|
||||
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
|
||||
*ALIAS_MODEL_ENV_VARS.values(),
|
||||
@@ -162,6 +177,14 @@ def claude_model_alias(
|
||||
candidate = model.strip().lower()
|
||||
if candidate in CLAUDE_MODEL_ALIASES:
|
||||
return candidate
|
||||
# Bracket variants of the family aliases (``sonnet[1m]``) are settable
|
||||
# aliases in their own right — the harness enumerates them in /model's
|
||||
# usage line and resolves the marker itself (the family pin plus the
|
||||
# marker on a pinned env). Stepping one down to its family would
|
||||
# silently drop the marker; refusing it blocks a switch the pane accepts.
|
||||
base, bracket, marker = candidate.partition("[")
|
||||
if bracket and marker.endswith("]") and base in CLAUDE_MODEL_ALIASES:
|
||||
return candidate
|
||||
pins = alias_pins(env)
|
||||
normalized = normalized_model_id(model)
|
||||
for alias, pinned in pins.items():
|
||||
@@ -202,4 +225,14 @@ def claude_model_command_arg(
|
||||
custom = environ.get(CUSTOM_MODEL_OPTION_ENV_VAR, "").strip()
|
||||
if custom and normalized_model_id(custom) == normalized_model_id(model):
|
||||
return custom
|
||||
candidate = model.strip()
|
||||
if not alias_pins(env) and candidate.lower().startswith("claude-"):
|
||||
# A full Anthropic model id names an EXACT generation, and ``/model``
|
||||
# on an unpinned (canonical-endpoint) session accepts full ids
|
||||
# verbatim — the same spelling the harness's own enumeration
|
||||
# resolves. Stepping down to the family alias here would switch to
|
||||
# claude's CURRENT generation of that family instead (picking
|
||||
# "Opus 4.8 (1M context)" used to type ``/model opus`` and land on
|
||||
# Opus 5).
|
||||
return candidate
|
||||
return claude_model_alias(model, env)
|
||||
|
||||
+572
-55
@@ -32,8 +32,8 @@ from omnigent.runtime.tool_result_replay import (
|
||||
)
|
||||
|
||||
# termios/tty are POSIX-only and drive the native (tmux/PTY) Claude terminal,
|
||||
# which is disabled on Windows. Guard the import (special-cased by mypy, which
|
||||
# type-checks on Linux) so importing this module never crashes the CLI there.
|
||||
# which is disabled on Windows. Guard the import so static checking keeps the
|
||||
# POSIX path typed without making module import crash the CLI on Windows.
|
||||
if sys.platform != "win32":
|
||||
import termios
|
||||
import tty
|
||||
@@ -81,8 +81,11 @@ from omnigent._wrapper_labels import (
|
||||
)
|
||||
from omnigent.claude_launcher import resolve_claude_launch
|
||||
from omnigent.claude_model_vocabulary import (
|
||||
ALIAS_MODEL_ENV_VARS,
|
||||
CUSTOM_MODEL_OPTION_ENV_VAR,
|
||||
CUSTOM_MODEL_OPTION_NAME_ENV_VAR,
|
||||
LEGACY_CUSTOM_SLOT_ROW_ID,
|
||||
claude_model_alias,
|
||||
)
|
||||
from omnigent.claude_native_bridge import (
|
||||
BRIDGE_ID_LABEL_KEY,
|
||||
@@ -112,7 +115,6 @@ from omnigent.host.daemon_launch import (
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.model_fallbacks import static_model_fallback
|
||||
from omnigent.native_coding_agents import native_shell_terminal_spec
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
@@ -132,7 +134,6 @@ from omnigent.native_terminal import (
|
||||
from omnigent.native_terminal import (
|
||||
terminal_attach_url as _attach_url,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
@@ -193,6 +194,18 @@ _CLAUDE_CODE_NESTED_SESSION_ENV = "CLAUDECODE"
|
||||
_CLAUDE_CODE_API_KEY_HELPER_TTL_ENV = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
|
||||
_CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS_ENV = "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"
|
||||
_CLAUDE_CODE_USE_GATEWAY_ENV = "CLAUDE_CODE_USE_GATEWAY"
|
||||
#: Kill-switch Claude Code treats as covering nonessential startup traffic;
|
||||
#: the probe strips it so speed knobs never mask harness output.
|
||||
_CLAUDE_NONESSENTIAL_TRAFFIC_ENV = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"
|
||||
_CLAUDE_MODEL_PROBE_TIMEOUT_S = 20.0
|
||||
#: Wall-clock cap for the per-alias resolution fan-out as a whole; aliases
|
||||
#: still unresolved when it expires keep their bare rows (the cache's
|
||||
#: revalidation retries them later). Startup dominates each run and
|
||||
#: stretches with box load (measured 0.7s–17s for the same command), so the
|
||||
#: concurrency covers a whole alias set in one wave and the budget fits one
|
||||
#: slow wave.
|
||||
_CLAUDE_ALIAS_RESOLUTION_BUDGET_S = 30.0
|
||||
_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY = 12
|
||||
_CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
|
||||
_CLAUDE_CODE_CUSTOM_HEADERS_ENV = "ANTHROPIC_CUSTOM_HEADERS"
|
||||
# Claude Code forwards the ANTHROPIC_CUSTOM_HEADERS value verbatim as
|
||||
@@ -233,15 +246,8 @@ _UCODE_CLAUDE_TIER_TO_ENV: dict[str, str] = {
|
||||
# See https://code.claude.com/docs/en/model-config#custom-model-options
|
||||
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = CUSTOM_MODEL_OPTION_ENV_VAR
|
||||
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = CUSTOM_MODEL_OPTION_NAME_ENV_VAR
|
||||
_UCODE_CLAUDE_CUSTOM_TIER = "sonnet_5"
|
||||
_UCODE_CLAUDE_CUSTOM_TIER = LEGACY_CUSTOM_SLOT_ROW_ID
|
||||
_UCODE_CLAUDE_CUSTOM_TIER_LABEL = "Sonnet 5"
|
||||
_CLAUDE_NATIVE_STATIC_MODEL_OPTIONS: tuple[tuple[str, str], ...] = (
|
||||
("fable", "Fable"),
|
||||
("opus", "Opus"),
|
||||
("sonnet", "Sonnet 4.6"),
|
||||
(_UCODE_CLAUDE_CUSTOM_TIER, _UCODE_CLAUDE_CUSTOM_TIER_LABEL),
|
||||
("haiku", "Haiku"),
|
||||
)
|
||||
_DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS = 900_000
|
||||
_SESSION_LABELS = {
|
||||
"omnigent.ui": "terminal",
|
||||
@@ -409,6 +415,57 @@ def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> b
|
||||
return host == "anthropic.com" or host.endswith(".anthropic.com")
|
||||
|
||||
|
||||
def _claude_family(token: str) -> str | None:
|
||||
"""
|
||||
The family alias a model id or alias folds onto, bracket markers dropped.
|
||||
|
||||
:param token: A picker id or model id, e.g. ``"opus[1m]"``,
|
||||
``"claude-opus-4-8"``.
|
||||
:returns: The family alias, e.g. ``"opus"``, or ``None`` for none.
|
||||
"""
|
||||
from omnigent.claude_model_vocabulary import claude_model_alias
|
||||
|
||||
alias = claude_model_alias(token, {})
|
||||
return alias.partition("[")[0] if alias else None
|
||||
|
||||
|
||||
def claude_catalog_serves_model(
|
||||
rows: list[dict[str, object]],
|
||||
model: str,
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> bool:
|
||||
"""
|
||||
Whether a launch of *model* is backed by this config's catalog.
|
||||
|
||||
An exact row — a picker id or its wire model — always serves. A canonical
|
||||
Anthropic id no row spells exactly still launches when the endpoint takes
|
||||
canonical spellings (``--model`` passes any string through, and a pane's
|
||||
``/model`` persists exactly this id) and the catalog lists the id's
|
||||
family: the same family fold ``/model`` applies to an unpinned canonical
|
||||
id. A gateway that routes only its own ids, and a family the catalog
|
||||
does not list, refuse — a genuinely stale pick still fails fast.
|
||||
|
||||
:param rows: Catalog rows, e.g.
|
||||
``[{"id": "opus", "model": "claude-opus-5"}]``.
|
||||
:param model: A picker id or model id, e.g. ``"claude-opus-4-8"``.
|
||||
:param claude_config: The resolved launch config, or ``None`` (Claude's
|
||||
own login).
|
||||
:returns: ``True`` when the launch can run *model* against this catalog.
|
||||
"""
|
||||
from omnigent.model_catalog_store import catalog_contains
|
||||
|
||||
if catalog_contains(rows, model):
|
||||
return True
|
||||
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
|
||||
return False
|
||||
if not model.lower().startswith("claude-"):
|
||||
return False
|
||||
family = _claude_family(model)
|
||||
return family is not None and any(
|
||||
_claude_family(str(row.get("id") or row.get("model") or "")) == family for row in rows
|
||||
)
|
||||
|
||||
|
||||
def resolve_claude_native_model_selection(
|
||||
model: str | None,
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
@@ -419,28 +476,21 @@ def resolve_claude_native_model_selection(
|
||||
extra Sonnet 5 row uses Omnigent's ``sonnet_5`` id because it occupies
|
||||
Claude Code's provider-configured custom model slot. Resolve that id to
|
||||
the exact custom option, preserving provider suffixes such as ``[1m]``.
|
||||
Direct Claude logins have no provider config, so they use the canonical
|
||||
Anthropic model id.
|
||||
Direct Claude logins have no provider config, so the pick degrades to
|
||||
the ``sonnet`` family alias, which Claude resolves itself.
|
||||
|
||||
On a gateway/Bedrock endpoint, a family alias with no tier pin (launch env
|
||||
or managed settings) resolves to the provider's default model — Claude
|
||||
Code would canonicalize it to an Anthropic id the endpoint rejects.
|
||||
Every other pick passes through verbatim: picker rows are pin-backed or
|
||||
vouched for by the harness's own probe, so rewriting one switches the
|
||||
pane to a model nobody chose (an unpinned family alias used to degrade
|
||||
to the provider's default model this way — a Fable pick landed on
|
||||
Opus). An out-of-band unpinned alias on a gateway endpoint now fails
|
||||
visibly at inference instead of silently running the default.
|
||||
|
||||
:param model: Persisted picker id, built-in alias, or concrete model id.
|
||||
:param claude_config: Resolved provider config for the terminal.
|
||||
:returns: A model identifier suitable for ``--model`` or ``/model``.
|
||||
"""
|
||||
if model != _UCODE_CLAUDE_CUSTOM_TIER:
|
||||
tier_env = _UCODE_CLAUDE_TIER_TO_ENV.get(model or "")
|
||||
if (
|
||||
tier_env is not None
|
||||
and claude_config is not None
|
||||
and not claude_config.env.get(tier_env)
|
||||
and not _serves_canonical_anthropic_ids(claude_config)
|
||||
):
|
||||
managed = _managed_claude_model_config()
|
||||
if managed is None or not managed.env.get(tier_env):
|
||||
return claude_config.model or model
|
||||
return model
|
||||
if claude_config is not None:
|
||||
custom_model = claude_config.env.get(_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV)
|
||||
@@ -451,26 +501,9 @@ def resolve_claude_native_model_selection(
|
||||
return provider_fallback
|
||||
if claude_config.model:
|
||||
return claude_config.model
|
||||
fallback = static_model_fallback(SUBSCRIPTION_KIND, "claude")
|
||||
if fallback is None:
|
||||
raise ValueError("Claude subscription fallback has no routable Sonnet model")
|
||||
exact_match = next(
|
||||
(
|
||||
model_id
|
||||
for model_id in fallback.model_ids
|
||||
if _claude_model_display_name("sonnet", model_id) == _UCODE_CLAUDE_CUSTOM_TIER_LABEL
|
||||
),
|
||||
None,
|
||||
)
|
||||
if exact_match is not None:
|
||||
return exact_match
|
||||
family_match = next(
|
||||
(model_id for model_id in fallback.model_ids if "claude-sonnet-" in model_id.lower()),
|
||||
None,
|
||||
)
|
||||
if family_match is None:
|
||||
raise ValueError("Claude subscription fallback has no routable Sonnet model")
|
||||
return family_match
|
||||
# No provider config pins the custom slot: hand Claude Code its own
|
||||
# ``sonnet`` alias and let it resolve the current Sonnet itself.
|
||||
return "sonnet"
|
||||
|
||||
|
||||
def claude_config_with_routed_arms_pinned(
|
||||
@@ -705,15 +738,472 @@ def claude_native_model_options(
|
||||
if not model_id:
|
||||
return []
|
||||
return [{"id": model_id, "model": model_id, "displayName": model_id, "isDefault": True}]
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"model": model_id,
|
||||
"displayName": label,
|
||||
"isDefault": False,
|
||||
}
|
||||
for model_id, label in _CLAUDE_NATIVE_STATIC_MODEL_OPTIONS
|
||||
# No curated fallback: an unconfigured shape's rows come from the probe
|
||||
# (the harness's own enumeration) or not at all — a hand-written list
|
||||
# here is exactly how a frozen "Sonnet 4.6" once shipped.
|
||||
return []
|
||||
|
||||
|
||||
def _parse_claude_model_aliases(stdout: str) -> list[str]:
|
||||
"""
|
||||
Extract the alias list from ``claude -p "/model"``'s printed usage line.
|
||||
|
||||
The harness prints e.g. ``Usage: /model <name>. Available: sonnet, opus,
|
||||
haiku, fable, best, sonnet[1m], opusplan, default, or a full model ID.``
|
||||
— its own enumeration of every settable alias. Parsing keeps zero model
|
||||
knowledge here: entries are taken verbatim, and only the trailing prose
|
||||
fragment (anything with whitespace) is dropped.
|
||||
|
||||
:param stdout: The probe run's stdout.
|
||||
:returns: Alias tokens in the harness's order; empty when no line parses.
|
||||
"""
|
||||
for line in stdout.splitlines():
|
||||
_, marker, tail = line.partition("Available:")
|
||||
if not marker:
|
||||
continue
|
||||
aliases: list[str] = []
|
||||
for entry in tail.split(","):
|
||||
token = entry.strip().rstrip(".")
|
||||
if token and " " not in token:
|
||||
aliases.append(token)
|
||||
return aliases
|
||||
return []
|
||||
|
||||
|
||||
def _parse_claude_current_model(stdout: str) -> dict[str, str]:
|
||||
"""
|
||||
Extract the resolved model from a stream-json ``/model`` probe run.
|
||||
|
||||
Two harness-owned facts, taken verbatim: the ``init`` event's exact
|
||||
model id, and the printed ``Current model:`` label with only the
|
||||
trailing ``(effort: …)`` suffix stripped — so labels like
|
||||
``Opus 4.8 (1M context)`` survive untouched.
|
||||
|
||||
:param stdout: The run's ``--output-format stream-json`` stdout.
|
||||
:returns: Whichever of ``{"model": …, "label": …}`` parsed.
|
||||
"""
|
||||
resolved: dict[str, str] = {}
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
if event.get("type") == "system" and event.get("subtype") == "init":
|
||||
model = event.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
resolved["model"] = model
|
||||
if event.get("type") == "result":
|
||||
for text_line in str(event.get("result", "")).splitlines():
|
||||
_, marker, tail = text_line.partition("Current model:")
|
||||
if not marker:
|
||||
continue
|
||||
label = re.sub(r"\s*\(effort:[^)]*\)\s*$", "", tail).strip()
|
||||
if label:
|
||||
resolved["label"] = label
|
||||
break
|
||||
return resolved
|
||||
|
||||
|
||||
def _claude_model_probe_invocation(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
extra_args: Sequence[str] = (),
|
||||
) -> tuple[str, list[str], dict[str, str]]:
|
||||
"""
|
||||
Assemble one headless ``/model`` probe invocation.
|
||||
|
||||
Shared by the alias-enumeration run and the per-alias resolution runs
|
||||
so the two cannot drift: same launch resolution, session env, speed
|
||||
env, and env-unset list.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param extra_args: Appended CLI args (e.g. ``--model <alias>``).
|
||||
:returns: ``(command, launch_args, env)`` ready to exec.
|
||||
"""
|
||||
from omnigent.claude_launcher import resolve_claude_launch
|
||||
|
||||
args = [
|
||||
"-p",
|
||||
"/model",
|
||||
# The probe asks one client-side question; the MCP fleet, session
|
||||
# persistence, and background chatter are irrelevant startup weight.
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config",
|
||||
'{"mcpServers":{}}',
|
||||
"--no-session-persistence",
|
||||
*extra_args,
|
||||
]
|
||||
if claude_config is not None and claude_config.api_key_helper:
|
||||
args.extend(("--settings", json.dumps({"apiKeyHelper": claude_config.api_key_helper})))
|
||||
command, launch_args = resolve_claude_launch("claude", args)
|
||||
env = dict(os.environ)
|
||||
env.update(build_native_claude_terminal_env(claude_config))
|
||||
env.update(
|
||||
{
|
||||
"DISABLE_TELEMETRY": "1",
|
||||
"DISABLE_ERROR_REPORTING": "1",
|
||||
"DISABLE_AUTOUPDATER": "1",
|
||||
}
|
||||
)
|
||||
# Mirrors the native terminal's env-unset list, plus the nonessential-
|
||||
# traffic kill-switch, so speed knobs never mask harness output.
|
||||
env.pop("DATABRICKS_CONFIG_PROFILE", None)
|
||||
env.pop(_CLAUDE_CODE_NESTED_SESSION_ENV, None)
|
||||
env.pop(_CLAUDE_NONESSENTIAL_TRAFFIC_ENV, None)
|
||||
if claude_config is not None and claude_config.api_key_helper:
|
||||
env.pop(_ANTHROPIC_API_KEY_ENV, None)
|
||||
return command, launch_args, env
|
||||
|
||||
|
||||
async def _resolve_claude_model_alias(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
alias: str,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Ask the harness what one printed alias resolves to.
|
||||
|
||||
A ``--model <alias>`` run in stream-json mode carries the exact model
|
||||
id in its init event and the human label in its ``Current model:``
|
||||
line; both are the harness's own resolution, never computed here.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param alias: The alias exactly as the harness printed it.
|
||||
:returns: Whichever of ``{"model": …, "label": …}`` resolved; empty on
|
||||
any failure (the alias keeps its bare row).
|
||||
"""
|
||||
command, launch_args, env = _claude_model_probe_invocation(
|
||||
claude_config,
|
||||
("--model", alias, "--output-format", "stream-json", "--verbose"),
|
||||
)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*launch_args,
|
||||
cwd=str(Path.home()),
|
||||
env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError:
|
||||
_logger.debug("Claude alias resolution could not launch for %r", alias, exc_info=True)
|
||||
return {}
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
|
||||
stdout, _stderr = await process.communicate()
|
||||
except (TimeoutError, asyncio.CancelledError) as exc:
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await process.wait()
|
||||
if isinstance(exc, asyncio.CancelledError):
|
||||
raise
|
||||
_logger.debug("Claude alias resolution timed out for %r", alias)
|
||||
return {}
|
||||
if process.returncode != 0:
|
||||
_logger.debug("Claude alias resolution exited %s for %r", process.returncode, alias)
|
||||
return {}
|
||||
return _parse_claude_current_model(stdout.decode(errors="replace"))
|
||||
|
||||
|
||||
async def _resolve_claude_model_aliases(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
aliases: Sequence[str],
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Resolve every printed alias concurrently, best-effort.
|
||||
|
||||
Bounded fan-out under one overall budget: whatever resolved in time is
|
||||
kept and the rest stay bare, so a hung harness cannot stretch the
|
||||
probe indefinitely.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param aliases: The harness-printed aliases.
|
||||
:returns: Non-empty resolutions keyed by alias.
|
||||
"""
|
||||
if not aliases:
|
||||
return {}
|
||||
semaphore = asyncio.Semaphore(_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY)
|
||||
|
||||
async def _bounded(alias: str) -> dict[str, str]:
|
||||
async with semaphore:
|
||||
return await _resolve_claude_model_alias(claude_config, alias)
|
||||
|
||||
tasks = {alias: asyncio.create_task(_bounded(alias)) for alias in aliases}
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_ALIAS_RESOLUTION_BUDGET_S):
|
||||
await asyncio.gather(*tasks.values())
|
||||
except TimeoutError:
|
||||
for task in tasks.values():
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||
done = sum(1 for task in tasks.values() if not task.cancelled())
|
||||
_logger.warning(
|
||||
"Claude alias resolution budget expired; keeping %d/%d resolutions",
|
||||
done,
|
||||
len(tasks),
|
||||
)
|
||||
return {
|
||||
alias: task.result()
|
||||
for alias, task in tasks.items()
|
||||
if not task.cancelled() and task.exception() is None and task.result()
|
||||
}
|
||||
|
||||
|
||||
def _claude_alias_row(alias: str, resolution: dict[str, str]) -> dict[str, object]:
|
||||
"""
|
||||
One picker row for a printed alias, shown as its resolution.
|
||||
|
||||
``id`` stays the alias — launches pass it through unchanged — while the
|
||||
display shows only the resolved label. Labels are normalized so every
|
||||
1M-context resolution (a ``[1m]``-suffixed model id) says so; the
|
||||
harness omits the marker for some of them (e.g. ``sonnet[1m]`` prints
|
||||
just "Sonnet 5").
|
||||
|
||||
:param alias: The harness-printed alias.
|
||||
:param resolution: Its resolution, possibly empty.
|
||||
:returns: The picker row.
|
||||
"""
|
||||
label = resolution.get("label")
|
||||
model = resolution.get("model") or alias
|
||||
if label and model.endswith("[1m]") and "1M context" not in label:
|
||||
label = f"{label} (1M context)"
|
||||
return {"id": alias, "model": model, "displayName": label or alias}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeModelProbe:
|
||||
"""One harness enumeration: picker rows plus the bare-launch default.
|
||||
|
||||
:param alias_rows: The printed aliases as deduplicated picker rows.
|
||||
:param default_model: The model the enumeration run itself launched on
|
||||
(its init event's ``model``) — what a no-pick launch of this config
|
||||
actually runs — or ``None`` when unreadable.
|
||||
:param default_label: The harness's own label for *default_model*, or
|
||||
``None``.
|
||||
"""
|
||||
|
||||
alias_rows: list[dict[str, object]]
|
||||
default_model: str | None = None
|
||||
default_label: str | None = None
|
||||
|
||||
|
||||
def _parse_claude_enumeration_aliases(stdout: str) -> list[str]:
|
||||
"""Extract the alias list from a stream-json enumeration run.
|
||||
|
||||
The ``Available:`` line lives inside the ``result`` event's text on a
|
||||
stream-json run; falls back to scanning the raw output so a plain-text
|
||||
run still parses.
|
||||
|
||||
:param stdout: The enumeration run's decoded stdout.
|
||||
:returns: Alias names, e.g. ``["sonnet", "opus", ...]``.
|
||||
"""
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(event, dict) and event.get("type") == "result":
|
||||
result_text = event.get("result")
|
||||
if isinstance(result_text, str):
|
||||
aliases = _parse_claude_model_aliases(result_text)
|
||||
if aliases:
|
||||
return aliases
|
||||
return _parse_claude_model_aliases(stdout)
|
||||
|
||||
|
||||
async def probe_claude_model_options(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> ClaudeModelProbe | None:
|
||||
"""
|
||||
Ask Claude Code itself which models it would offer, and its default.
|
||||
|
||||
The harness is the source of truth: a short ``claude -p "/model"`` run
|
||||
(stream-json, so its init event also names the model a bare launch of
|
||||
this config actually runs — the truthful "Default") makes Claude Code
|
||||
print its own alias list. Each printed alias is then resolved to its
|
||||
concrete model by a per-alias harness run. All outputs are read
|
||||
verbatim; no selection semantics are replicated here. Runs for every
|
||||
config shape, including the bare subscription launch (``None`` config).
|
||||
|
||||
:param claude_config: The resolved native launch config
|
||||
(:func:`resolve_native_claude_config`), or ``None``.
|
||||
:returns: The probe result, or ``None`` when the probe failed (callers
|
||||
fall back to the configured/static rows).
|
||||
"""
|
||||
command, launch_args, env = _claude_model_probe_invocation(
|
||||
claude_config, ("--output-format", "stream-json", "--verbose")
|
||||
)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*launch_args,
|
||||
cwd=str(Path.home()),
|
||||
env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError:
|
||||
_logger.warning("Claude model probe could not launch the claude CLI", exc_info=True)
|
||||
return None
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
|
||||
stdout, stderr = await process.communicate()
|
||||
except (TimeoutError, asyncio.CancelledError):
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await process.wait()
|
||||
_logger.warning("Claude model probe timed out; keeping configured rows only")
|
||||
return None
|
||||
if process.returncode != 0:
|
||||
_logger.warning(
|
||||
"Claude model probe exited %s: %s",
|
||||
process.returncode,
|
||||
stderr.decode(errors="replace").strip()[-500:],
|
||||
)
|
||||
return None
|
||||
text = stdout.decode(errors="replace")
|
||||
aliases = _parse_claude_enumeration_aliases(text)
|
||||
# The enumeration run's own init event names what a bare launch runs —
|
||||
# the harness's truthful Default.
|
||||
default_resolution = _parse_claude_current_model(text)
|
||||
# The picker renders its own top-level Default choice (launch with no
|
||||
# model), which is exactly what the harness's ``default`` alias does —
|
||||
# listing it again would duplicate that row.
|
||||
aliases = [alias for alias in aliases if alias != "default"]
|
||||
resolutions = await _resolve_claude_model_aliases(claude_config, aliases)
|
||||
alias_rows: list[dict[str, object]] = []
|
||||
seen_models: set[object] = set()
|
||||
for alias in aliases:
|
||||
row = _claude_alias_row(alias, resolutions.get(alias, {}))
|
||||
# Distinct aliases can resolve to a model an earlier row already
|
||||
# covers (``best``, ``fable[1m]``, and ``opusplan`` all do today);
|
||||
# repeating the model is picker noise.
|
||||
if row["model"] in seen_models:
|
||||
continue
|
||||
seen_models.add(row["model"])
|
||||
alias_rows.append(row)
|
||||
return ClaudeModelProbe(
|
||||
alias_rows=alias_rows,
|
||||
default_model=default_resolution.get("model"),
|
||||
default_label=default_resolution.get("label"),
|
||||
)
|
||||
|
||||
|
||||
def claude_catalog_fingerprint(claude_config: ClaudeNativeUcodeConfig | None) -> str:
|
||||
"""The launch-config fingerprint keying claude's shared model catalog.
|
||||
|
||||
One formula for every consumer (host boot probe, runner launch, session
|
||||
listing), so they read and write the same catalog file.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: A stable fingerprint string.
|
||||
"""
|
||||
from omnigent.model_catalog_store import fingerprint_of
|
||||
|
||||
return fingerprint_of(
|
||||
"claude-native",
|
||||
sorted(claude_config.env.items()) if claude_config is not None else None,
|
||||
claude_config.api_key_helper if claude_config is not None else None,
|
||||
claude_config.model if claude_config is not None else None,
|
||||
)
|
||||
|
||||
|
||||
async def claude_model_catalog(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
The harness-truth catalog: probe rows with one truthful ``isDefault``.
|
||||
|
||||
Rows come from the harness's own enumeration alone (no configured/static
|
||||
merge). Servability filtering matches the listing composition: on a
|
||||
non-canonical endpoint, aliases resolving to bare Anthropic ids are
|
||||
dropped. The default marker is what a Default launch of this config
|
||||
actually runs: the config's own launch pin when the provider resolves
|
||||
one (those launches pass ``--model`` explicitly), else the enumeration
|
||||
run's init-event model (a bare subscription launch). It is matched onto
|
||||
its row, or appended as its own row when the catalog lacks it (a
|
||||
``settings.json`` pin, say) and the endpoint can serve it.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: Catalog rows, or ``None`` when the probe failed.
|
||||
"""
|
||||
probe = await probe_claude_model_options(claude_config)
|
||||
if probe is None:
|
||||
return None
|
||||
rows = list(probe.alias_rows)
|
||||
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
|
||||
rows = [row for row in rows if not str(row.get("model", "")).startswith("claude-")]
|
||||
|
||||
configured_pin = claude_config.model if claude_config is not None else None
|
||||
default_model = configured_pin or probe.default_model
|
||||
marked = False
|
||||
out: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
is_default = (
|
||||
bool(default_model)
|
||||
and not marked
|
||||
and (row.get("model") == default_model or row.get("id") == default_model)
|
||||
)
|
||||
if is_default:
|
||||
marked = True
|
||||
out.append({**row, "isDefault": True})
|
||||
else:
|
||||
out.append({key: value for key, value in row.items() if key != "isDefault"})
|
||||
if default_model and not marked:
|
||||
# Append the observed default as its own honest row — but never
|
||||
# claim a bare Anthropic id is launchable on an endpoint that
|
||||
# rejects that spelling.
|
||||
servable = (
|
||||
claude_config is None
|
||||
or _serves_canonical_anthropic_ids(claude_config)
|
||||
or not default_model.startswith("claude-")
|
||||
)
|
||||
if servable:
|
||||
# The probe's printed label describes the ENUMERATION run's
|
||||
# model; it only names a config-pinned default when the two are
|
||||
# the same model.
|
||||
label = (
|
||||
probe.default_label
|
||||
if default_model == probe.default_model and probe.default_label
|
||||
else default_model
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"id": default_model,
|
||||
"model": default_model,
|
||||
"displayName": label,
|
||||
"isDefault": True,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def claude_launch_catalog(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
The shared catalog for this launch config: read the store, probe on miss.
|
||||
|
||||
The store read is what keeps launches fast once the host's boot probe
|
||||
(or a previous launch) has run; a cold miss pays one probe and persists
|
||||
the answer for every later consumer.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
|
||||
"""
|
||||
from omnigent import model_catalog_store
|
||||
|
||||
fingerprint = claude_catalog_fingerprint(claude_config)
|
||||
return await model_catalog_store.ensure_catalog(
|
||||
"claude-native", fingerprint, lambda: claude_model_catalog(claude_config)
|
||||
)
|
||||
|
||||
|
||||
def build_native_claude_terminal_env(
|
||||
@@ -2234,9 +2724,27 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
|
||||
family.base_url,
|
||||
family.default_model,
|
||||
)
|
||||
# Pin the alias vocabulary to the entry's declared models, so ``/model``
|
||||
# and the harness probe resolve aliases inside this gateway's routable
|
||||
# set instead of falling back to canonical Anthropic ids the gateway
|
||||
# rejects. The ``models:`` map's flat tier keys (``opus``/``sonnet``/…)
|
||||
# pin their aliases directly; ``models.default`` pins its own family's
|
||||
# alias when nothing else declared that family.
|
||||
pin_env: dict[str, str] = {}
|
||||
for alias, env_var in ALIAS_MODEL_ENV_VARS.items():
|
||||
pinned = family.models.get(alias)
|
||||
if isinstance(pinned, str) and pinned.strip():
|
||||
pin_env[env_var] = pinned.strip()
|
||||
if family.default_model:
|
||||
default_alias = claude_model_alias(family.default_model, env={})
|
||||
base_alias = (default_alias or "").partition("[")[0]
|
||||
default_env_var = ALIAS_MODEL_ENV_VARS.get(base_alias)
|
||||
if default_env_var:
|
||||
pin_env.setdefault(default_env_var, family.default_model)
|
||||
return ClaudeNativeUcodeConfig(
|
||||
env={
|
||||
_UCODE_CLAUDE_BASE_URL_ENV: family.base_url,
|
||||
**pin_env,
|
||||
# Disable beta flags gateways reject (400 "invalid beta flag");
|
||||
# skip when CLAUDE_CODE_USE_GATEWAY=1 to keep tool search enabled.
|
||||
**(
|
||||
@@ -2247,6 +2755,15 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
|
||||
},
|
||||
api_key_helper=api_key_helper,
|
||||
model=family.default_model,
|
||||
# The declared models are exactly what this entry can route.
|
||||
routable_models=tuple(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*pin_env.values(),
|
||||
*([family.default_model] if family.default_model else []),
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,23 +55,22 @@ from urllib import error, request
|
||||
from omnigent._platform import stable_user_id
|
||||
from omnigent.claude_model_vocabulary import MODEL_VOCABULARY_ENV_VARS
|
||||
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
|
||||
from omnigent.claude_native_status import CONTEXT_RAW_FILE
|
||||
from omnigent.json_types import JsonObject as _JsonObject
|
||||
from omnigent.kiro_native_bridge import bridge_root as kiro_bridge_root
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec
|
||||
from omnigent.inner.os_env import OSEnvironment
|
||||
from omnigent.llms.context_window import ModelPricing
|
||||
|
||||
from omnigent.inner.bundle_skills import claude_native_skill_args
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.hook_scripts.subagent_router import (
|
||||
AGENT_TOOL_MATCHER as CLAUDE_SUBAGENT_TOOL_MATCHER,
|
||||
)
|
||||
from omnigent.inner.os_env import OSEnvironment, create_os_environment
|
||||
from omnigent.reasoning_effort import CLAUDE_EFFORTS
|
||||
from omnigent.tools.base import Tool, ToolContext
|
||||
from omnigent.tools.builtins.os_env import build_os_env_tools
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,6 +95,10 @@ _RECENT_LOCAL_COMMAND_LINE_LIMIT = 200
|
||||
_RECENT_LOCAL_COMMAND_WINDOW_S = 10.0
|
||||
_FORKED_FROM_LINE_LIMIT = 200
|
||||
_TOOL_RELAY_FILE = "tool_relay.json"
|
||||
# Shell-sourceable sibling of tool_relay.json so the curl-based hook
|
||||
# commands can discover the live relay without a JSON parser. Re-written
|
||||
# on every relay start, so hooks survive runner restarts (new port).
|
||||
_TOOL_RELAY_ENV_FILE = "tool_relay.env"
|
||||
_TMUX_FILE = "tmux.json"
|
||||
_PERMISSION_HOOK_FILE = "permission_hook.json"
|
||||
_CONTEXT_FILE = "context.json"
|
||||
@@ -176,11 +179,58 @@ _PASTED_PLACEHOLDER_PREFIX = "[Pasted text"
|
||||
# whether the draft is rendered in the input box. Short enough to fit
|
||||
# on the prompt row of a default 80-column detached pane.
|
||||
_DRAFT_NEEDLE_MAX_CHARS = 24
|
||||
# Mode footer Claude Code renders below its input box, keyed by
|
||||
# ``--permission-mode`` value. There is no non-interactive mode command, so
|
||||
# a live switch cycles with shift+tab and reads this footer to know where it
|
||||
# landed. The prompting mode is ``default`` on the CLI, "manual" on screen.
|
||||
_PERMISSION_MODE_FOOTERS: dict[str, str] = {
|
||||
"default": "manual mode on",
|
||||
"acceptEdits": "accept edits on",
|
||||
"plan": "plan mode on",
|
||||
"auto": "auto mode on",
|
||||
}
|
||||
# Modes shift+tab can reach. ``dontAsk`` is never in the cycle and
|
||||
# ``bypassPermissions`` only joins it when launched into, so both are
|
||||
# rejected up front.
|
||||
CYCLEABLE_PERMISSION_MODES = frozenset(_PERMISSION_MODE_FOOTERS)
|
||||
# Cap on shift+tab presses. The cycle is 3-5 modes wide depending on which
|
||||
# optional modes are enabled, so a full lap plus slack proves the target is
|
||||
# unreachable rather than slow.
|
||||
_MODE_CYCLE_MAX_PRESSES = 8
|
||||
# Wait for the footer to repaint after a shift+tab before reading it.
|
||||
_MODE_FOOTER_SETTLE_TIMEOUT_S = 2.0
|
||||
_MODE_FOOTER_POLL_INTERVAL_S = 0.1
|
||||
# Footer Claude Code's interactive ``/model`` picker renders while it is open.
|
||||
# Omnigent never drives that picker — it switches with ``/model <id>`` — but a
|
||||
# picker the person opened by hand covers the input box, so an injection would
|
||||
# be lost; the readiness gate treats it as "not ready".
|
||||
_MODEL_PICKER_OPEN_HINT = "use this session only"
|
||||
# Header of the ctrl+r prompt-history search. Like the picker it covers the
|
||||
# input box, but its selected history row renders the composer's ``❯`` glyph
|
||||
# above the filter box's frame rule, so the readiness scan alone reads it as
|
||||
# a mounted input box — keystrokes would land in the filter field, and the
|
||||
# submit Enter would replay whatever old prompt is selected.
|
||||
_REVERSE_SEARCH_OPEN_HINT = "Search prompts ·"
|
||||
# Surfaces a person can leave covering the composer from the embedded
|
||||
# terminal. Each documents Escape as its dismissal ("Esc to cancel"), which
|
||||
# closes it without committing anything and restores the empty input box, so
|
||||
# an injected web-UI message reclaims the pane instead of typing into the
|
||||
# surface. Shell mode (``!``) also occupies the composer but has no safe
|
||||
# textual marker: its footer line ("! for shell mode") appears verbatim in
|
||||
# the ``?`` shortcuts panel while the composer is fully usable.
|
||||
_OCCUPIED_INPUT_HINTS: tuple[str, ...] = (
|
||||
_REVERSE_SEARCH_OPEN_HINT,
|
||||
_MODEL_PICKER_OPEN_HINT,
|
||||
)
|
||||
# How long to keep dismissing an occupying surface that verifiably stays on
|
||||
# screen, and the spacing between repeated Escapes — a busy repaint can
|
||||
# swallow one (same reasoning as ``_SUBMIT_RETRY_INTERVAL_S``). The spacing
|
||||
# also bounds a residual hazard: were a successful Escape's repaint to
|
||||
# outlast it, the stale hint would draw a retry onto the bare composer
|
||||
# (interrupting a turn). 0.75s dwarfs a TUI repaint, so that window is
|
||||
# accepted rather than confirmation-gated.
|
||||
_OCCUPIED_INPUT_DISMISS_TIMEOUT_S = 3.0
|
||||
_OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S = 0.75
|
||||
# Titles of the confirmation dialog Claude Code pops when a switch invalidates
|
||||
# the prompt cache — one component, titled for what is being switched. It only
|
||||
# appears on a session with history, and it took ~1.9s to render on a warm
|
||||
@@ -224,6 +274,14 @@ _INVOCATION_SETTINGS_FILE = "claude-settings.json"
|
||||
ToolExecutor = Callable[[str, _JsonObject], Awaitable[object]]
|
||||
|
||||
|
||||
class ClaudePromptTimeout(RuntimeError):
|
||||
"""Claude Code's input box did not render before delivery timed out."""
|
||||
|
||||
|
||||
class TmuxSessionNotAdvertised(RuntimeError):
|
||||
"""The bridge's tmux target was not advertised before the deadline."""
|
||||
|
||||
|
||||
def _absolute_syntactic_path(path: Path) -> Path:
|
||||
"""
|
||||
Return an absolute path without following symlinks.
|
||||
@@ -417,6 +475,11 @@ class TranscriptReadResult:
|
||||
entry was scanned.
|
||||
:param latest_model: ``message.model`` from the most recent
|
||||
assistant entry, or ``None``.
|
||||
:param latest_custom_title: ``customTitle`` from the most recent
|
||||
``custom-title`` record — the explicit title a ``/rename`` typed
|
||||
in the Claude Code pane writes. ``None`` when no such record was
|
||||
scanned. Claude's own auto-generated ``aiTitle`` is deliberately
|
||||
not surfaced here; Omnigent titles unnamed sessions itself.
|
||||
"""
|
||||
|
||||
line_cursor: int
|
||||
@@ -425,6 +488,7 @@ class TranscriptReadResult:
|
||||
items: list[ClaudeTranscriptItem]
|
||||
latest_usage: dict[str, int] | None = None
|
||||
latest_model: str | None = None
|
||||
latest_custom_title: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -1184,6 +1248,48 @@ def read_model_env(bridge_dir: Path) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def record_model_vocabulary(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
launch_env: Mapping[str, str] | None,
|
||||
launch_model: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Persist the launch's model vocabulary after the bridge dir exists.
|
||||
|
||||
The runner prepares the bridge before it resolves the provider config,
|
||||
so the vocabulary (alias pins + custom slot) and the launch model land
|
||||
here in a second write once known — the same keys
|
||||
:func:`prepare_bridge_dir` records on the CLI path, so
|
||||
:func:`read_model_env` / :func:`read_launch_model` serve both paths
|
||||
identically.
|
||||
|
||||
:param bridge_dir: Bridge directory path.
|
||||
:param launch_env: The resolved launch env (pins + custom slot), or
|
||||
``None`` for a bare subscription launch.
|
||||
:param launch_model: The model the launch pins via ``--model``, or
|
||||
``None``.
|
||||
:returns: None.
|
||||
"""
|
||||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||||
if not isinstance(config, dict):
|
||||
return
|
||||
model_env = {
|
||||
key: launch_env[key]
|
||||
for key in MODEL_VOCABULARY_ENV_VARS
|
||||
if launch_env is not None and launch_env.get(key)
|
||||
}
|
||||
changed = False
|
||||
if model_env and config.get("model_env") != model_env:
|
||||
config["model_env"] = model_env
|
||||
changed = True
|
||||
if launch_model and config.get("launch_model") != launch_model:
|
||||
config["launch_model"] = launch_model
|
||||
changed = True
|
||||
if changed:
|
||||
_write_json_file(bridge_dir / _CONFIG_FILE, config)
|
||||
|
||||
|
||||
def read_bridge_id(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the opaque bridge id from bridge config.
|
||||
@@ -1356,22 +1462,18 @@ def build_hook_settings(
|
||||
"command": command,
|
||||
}
|
||||
# ``MessageDisplay`` fires once per streamed assistant-text chunk and
|
||||
# Claude blocks on the hook, so it gets a dedicated stdlib-only
|
||||
# appender module instead of the heavier observer ``hook`` above —
|
||||
# the per-chunk subprocess must stay cheap. It just appends the
|
||||
# chunk to ``<bridge_dir>/message_deltas.jsonl``; the forwarder tails
|
||||
# that file and publishes ``response.output_text.delta`` events.
|
||||
message_display_command_parts = [
|
||||
python,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_message_display_hook",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
]
|
||||
# Claude blocks on the hook, so the hot path must not even pay an
|
||||
# interpreter spawn: a /bin/sh appender writes Claude's raw payload
|
||||
# (flattened to one line — JSON strings never carry literal newlines)
|
||||
# to ``message_deltas.jsonl``. The reader parses records by key and
|
||||
# skips non-delta lines, so raw envelopes need no Python-side shaping.
|
||||
deltas_quoted = shlex.quote(str(bridge_dir / MESSAGE_DELTAS_FILE))
|
||||
message_display_hook = {
|
||||
"type": "command",
|
||||
"command": shlex.join(message_display_command_parts),
|
||||
"command": (
|
||||
"p=$(cat | tr -d '\\r\\n'); "
|
||||
f'[ -n "$p" ] && printf \'%s\\n\' "$p" >> {deltas_quoted}; :'
|
||||
),
|
||||
}
|
||||
hooks: dict[str, list[_JsonObject]] = {
|
||||
"SessionStart": [{"hooks": [session_start_hook]}],
|
||||
@@ -1462,19 +1564,43 @@ def build_hook_settings(
|
||||
hooks["PermissionRequest"] = [{"hooks": [permission_hook]}]
|
||||
|
||||
# Policy-gate native Claude Code tools, not just relay/MCP tools.
|
||||
evaluate_policy_command_parts = [
|
||||
python,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_hook",
|
||||
"evaluate-policy",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
]
|
||||
# The hook is a bare curl against the relay's evaluate-policy
|
||||
# endpoint (which owns all transformation and verdict logic), so
|
||||
# Claude's blocking tool-call path pays no interpreter spawn. The
|
||||
# relay's coordinates are re-read from tool_relay.env on every
|
||||
# event, so hooks survive runner restarts. Before the relay exists
|
||||
# (it starts in the background at session create — a very early
|
||||
# hook can beat it) or when curl fails, the same stdin is
|
||||
# replayed into the Python hook, which owns the direct-server
|
||||
# path and the phase-aware fail-closed contract — exactly the
|
||||
# pre-curl behavior.
|
||||
relay_env_quoted = shlex.quote(str(bridge_dir / _TOOL_RELAY_ENV_FILE))
|
||||
evaluate_policy_python = shlex.join(
|
||||
[
|
||||
python,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_hook",
|
||||
"evaluate-policy",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
]
|
||||
)
|
||||
evaluate_policy_command = (
|
||||
"p=$(cat); "
|
||||
f"if [ -r {relay_env_quoted} ]; then . {relay_env_quoted}; "
|
||||
"out=$(printf '%s' \"$p\" | curl -sf --max-time 86400 "
|
||||
'-H "Authorization: Bearer $OMNIGENT_RELAY_TOKEN" '
|
||||
"-H 'Content-Type: application/json' --data-binary @- "
|
||||
'"$OMNIGENT_RELAY_URL/hook/claude/evaluate-policy" 2>/dev/null) '
|
||||
"&& { printf '%s' \"$out\"; exit 0; }; fi; "
|
||||
f"printf '%s' \"$p\" | {evaluate_policy_python}"
|
||||
)
|
||||
evaluate_policy_hook: _JsonObject = {
|
||||
"type": "command",
|
||||
"command": shlex.join(evaluate_policy_command_parts),
|
||||
"command": evaluate_policy_command,
|
||||
}
|
||||
|
||||
# In bypassPermissions mode PermissionRequest never fires, so
|
||||
# AskUserQuestion needs its own PreToolUse hook to surface the
|
||||
# form. It's a no-op in other modes to avoid double-surfacing.
|
||||
@@ -1559,20 +1685,20 @@ def build_hook_settings(
|
||||
if api_key_helper:
|
||||
settings["apiKeyHelper"] = api_key_helper
|
||||
# Override Claude Code's statusLine so we receive its stdin (the
|
||||
# only place ``context_window`` surfaces). Chain to whatever the
|
||||
# user had globally so claude-hud / their bar still renders.
|
||||
status_parts = [
|
||||
python,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_status",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
]
|
||||
# only place ``context_window`` surfaces). A /bin/sh shim captures
|
||||
# the raw payload atomically (no interpreter spawn on Claude's
|
||||
# blocking statusLine path — the forwarder normalizes it into
|
||||
# ``context.json``) and chains to whatever the user had globally so
|
||||
# claude-hud / their bar still renders.
|
||||
raw_quoted = shlex.quote(str(bridge_dir / CONTEXT_RAW_FILE))
|
||||
status_command = (
|
||||
f"p=$(cat); printf '%s' \"$p\" > {raw_quoted}.$$.tmp"
|
||||
f" && mv -f {raw_quoted}.$$.tmp {raw_quoted}"
|
||||
)
|
||||
chain_command = read_user_status_line_command()
|
||||
if chain_command is not None:
|
||||
status_parts.extend(["--chain", chain_command])
|
||||
settings["statusLine"] = {"type": "command", "command": shlex.join(status_parts)}
|
||||
status_command += f"; printf '%s' \"$p\" | ( {chain_command} )"
|
||||
settings["statusLine"] = {"type": "command", "command": status_command}
|
||||
return settings
|
||||
|
||||
|
||||
@@ -1730,6 +1856,9 @@ def augment_claude_args(
|
||||
)
|
||||
if append_system_prompt:
|
||||
args.extend(["--append-system-prompt", append_system_prompt])
|
||||
# Imported here: bundle-skills parsing rides the spec graph; launch-only.
|
||||
from omnigent.inner.bundle_skills import claude_native_skill_args
|
||||
|
||||
args.extend(
|
||||
claude_native_skill_args(
|
||||
bundle_dir,
|
||||
@@ -2142,11 +2271,14 @@ def read_transcript_items_since(
|
||||
|
||||
Claude Code writes append-only JSONL records whose ``message``
|
||||
payloads include user prompts, assistant text, native tool calls,
|
||||
and native tool results. This parser intentionally ignores
|
||||
metadata records (title, file-history, permission mode, system
|
||||
bookkeeping) and raw ``thinking`` blocks, while translating the
|
||||
user-visible semantic records into Omnigent item types the web UI
|
||||
already understands.
|
||||
and native tool results. This parser intentionally renders no
|
||||
conversation item for metadata records (title, file-history,
|
||||
permission mode, system bookkeeping) or raw ``thinking`` blocks,
|
||||
while translating the user-visible semantic records into Omnigent
|
||||
item types the web UI already understands. Some metadata is still
|
||||
read for out-of-band mirroring rather than dropped outright — a
|
||||
``custom-title`` record surfaces on
|
||||
:attr:`TranscriptReadResult.latest_custom_title`.
|
||||
|
||||
:param transcript_path: Claude transcript path, e.g.
|
||||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||||
@@ -2207,6 +2339,7 @@ def read_transcript_items_since_with_position(
|
||||
active_settled_id = settled_response_id
|
||||
latest_usage: dict[str, int] | None = None
|
||||
latest_model: str | None = None
|
||||
latest_custom_title: str | None = None
|
||||
for record in read_result.records:
|
||||
if record.text is None:
|
||||
continue
|
||||
@@ -2236,6 +2369,9 @@ def read_transcript_items_since_with_position(
|
||||
model = _model_from_transcript_entry(entry)
|
||||
if model is not None:
|
||||
latest_model = model
|
||||
custom_title = _custom_title_from_transcript_entry(entry)
|
||||
if custom_title is not None:
|
||||
latest_custom_title = custom_title
|
||||
return TranscriptReadResult(
|
||||
line_cursor=read_result.line_cursor,
|
||||
byte_offset=read_result.byte_offset,
|
||||
@@ -2243,6 +2379,7 @@ def read_transcript_items_since_with_position(
|
||||
items=items,
|
||||
latest_usage=latest_usage,
|
||||
latest_model=latest_model,
|
||||
latest_custom_title=latest_custom_title,
|
||||
)
|
||||
|
||||
|
||||
@@ -2295,6 +2432,7 @@ def read_transcript_items_from_offset(
|
||||
active_settled_id = settled_response_id
|
||||
latest_usage: dict[str, int] | None = None
|
||||
latest_model: str | None = None
|
||||
latest_custom_title: str | None = None
|
||||
for record in read_result.records:
|
||||
if record.text is None:
|
||||
continue
|
||||
@@ -2325,6 +2463,9 @@ def read_transcript_items_from_offset(
|
||||
model = _model_from_transcript_entry(entry)
|
||||
if model is not None:
|
||||
latest_model = model
|
||||
custom_title = _custom_title_from_transcript_entry(entry)
|
||||
if custom_title is not None:
|
||||
latest_custom_title = custom_title
|
||||
return TranscriptReadResult(
|
||||
line_cursor=read_result.line_cursor,
|
||||
byte_offset=read_result.byte_offset,
|
||||
@@ -2332,6 +2473,7 @@ def read_transcript_items_from_offset(
|
||||
items=items,
|
||||
latest_usage=latest_usage,
|
||||
latest_model=latest_model,
|
||||
latest_custom_title=latest_custom_title,
|
||||
)
|
||||
|
||||
|
||||
@@ -2871,6 +3013,11 @@ def inject_user_message(
|
||||
(see :func:`_wait_for_claude_prompt_ready`). The second gate closes
|
||||
a race on freshly-created sessions where the first message would
|
||||
otherwise be typed into a still-booting TUI and silently dropped.
|
||||
Between the two, any surface the person left covering the composer
|
||||
from the embedded terminal — a ctrl+r history search, a hand-opened
|
||||
``/model`` picker — is dismissed with Escape
|
||||
(see :func:`_restore_occupied_input`), so the message reclaims the
|
||||
input box instead of typing into that surface.
|
||||
|
||||
Delivered as one bracketed paste via ``tmux load-buffer`` (from a
|
||||
temp file) + ``paste-buffer -p`` so interior newlines ride as raw CR
|
||||
@@ -2904,6 +3051,11 @@ def inject_user_message(
|
||||
after repeated submit Enters (message not delivered).
|
||||
"""
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
# A ctrl+r history search or hand-opened /model picker left covering
|
||||
# the composer swallows everything typed below — and can hide the
|
||||
# prompt glyph, wedging the readiness gate — so reclaim the input box
|
||||
# before waiting on it.
|
||||
_restore_occupied_input(info["socket_path"], info["tmux_target"])
|
||||
# tmux.json only means the tmux session exists; Claude Code's input
|
||||
# box mounts a few seconds later. Block until the prompt renders so
|
||||
# the first message isn't typed into a still-booting TUI and dropped.
|
||||
@@ -3106,10 +3258,16 @@ def kill_session(
|
||||
there is no live session to kill.
|
||||
:returns: None.
|
||||
:raises RuntimeError: If the tmux target is not advertised in
|
||||
time, or if the ``tmux kill-session`` invocation fails.
|
||||
time, or if ``tmux kill-session`` fails for an unexpected reason.
|
||||
"""
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
|
||||
try:
|
||||
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
|
||||
except RuntimeError as exc:
|
||||
detail = str(exc).lower()
|
||||
if "can't find session" in detail or "no server running on" in detail:
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
def inject_slash_command(
|
||||
@@ -3123,6 +3281,11 @@ def inject_slash_command(
|
||||
"""
|
||||
Type a Claude Code slash command into the tmux pane and submit it.
|
||||
|
||||
A surface the person left covering the composer from the embedded
|
||||
terminal (ctrl+r history search, hand-opened ``/model`` picker) is
|
||||
dismissed first — see :func:`_restore_occupied_input` — so the
|
||||
command cannot be typed into it.
|
||||
|
||||
:param bridge_dir: Bridge directory path, e.g.
|
||||
``/tmp/omnigent/claude-native/<digest>``.
|
||||
:param command: Single-line slash command including the leading
|
||||
@@ -3159,6 +3322,10 @@ def inject_slash_command(
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
socket_path = info["socket_path"]
|
||||
tmux_target = info["tmux_target"]
|
||||
# Same reclaim as inject_user_message: a ctrl+r search or hand-opened
|
||||
# /model picker left covering the composer would swallow the C-u and
|
||||
# the typed command.
|
||||
_restore_occupied_input(socket_path, tmux_target)
|
||||
# ``C-u`` clears any draft the user is mid-typing; otherwise the
|
||||
# paste below concatenates with their text and Enter submits
|
||||
# ``<their-draft>/effort high`` as a turn. Unlike Escape it does
|
||||
@@ -3266,6 +3433,171 @@ def _confirm_tui_dialog(
|
||||
return False
|
||||
|
||||
|
||||
def _permission_mode_from_pane(pane: str) -> str | None:
|
||||
"""
|
||||
Read Claude Code's current permission mode off a captured pane.
|
||||
|
||||
The footer (``⏵⏵ auto mode on``, ``⏸ plan mode on``, ...) always sits
|
||||
below the input box's closing rule, so the scan starts there rather than
|
||||
at a fixed offset from the bottom: the footer's height scales with
|
||||
concurrent subagents, which a fixed window cannot bound (the same reason
|
||||
:func:`_claude_prompt_rendered` anchors on :func:`_is_box_rule`). Anchoring
|
||||
also excludes transcript text structurally — a mode name Claude echoed
|
||||
while *discussing* modes sits above the box and can't be misread as live.
|
||||
|
||||
:param pane: Captured pane text from :func:`_capture_pane`.
|
||||
:returns: The ``--permission-mode`` value for the rendered footer,
|
||||
e.g. ``"auto"``, or ``None`` when no footer is visible (the
|
||||
pane is mid-repaint, or the mode is one with no footer).
|
||||
"""
|
||||
lines = [line for line in pane.splitlines() if line.strip()]
|
||||
# Below the last box rule is the footer region. With no rule the input box
|
||||
# isn't mounted; fall back to the tail so a footer still reads during boot.
|
||||
last_rule = max((i for i, line in enumerate(lines) if _is_box_rule(line)), default=None)
|
||||
region = lines[last_rule + 1 :] if last_rule is not None else lines[-_PROMPT_SCAN_TAIL_LINES:]
|
||||
for line in reversed(region):
|
||||
for mode, footer in _PERMISSION_MODE_FOOTERS.items():
|
||||
if footer in line:
|
||||
return mode
|
||||
return None
|
||||
|
||||
|
||||
def _read_settled_permission_mode(
|
||||
socket_path: str,
|
||||
tmux_target: str,
|
||||
*,
|
||||
previous: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Poll the pane until its permission-mode footer settles.
|
||||
|
||||
A shift+tab repaints the footer asynchronously, so an immediate
|
||||
capture can read nothing — or, worse, still read the PREVIOUS mode
|
||||
and make the cycler believe the keystroke did nothing. Passing
|
||||
*previous* waits for the footer to actually change.
|
||||
|
||||
:param socket_path: Absolute path to the tmux socket.
|
||||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||||
:param previous: Mode read before the keystroke that prompted this
|
||||
read, e.g. ``"plan"``. The pane keeps rendering it until the TUI
|
||||
repaints, so a read that returns it is treated as not-yet-settled
|
||||
and retried; ``None`` accepts the first mode seen (the initial
|
||||
read, where there is nothing to change from).
|
||||
:returns: The rendered mode, or ``None`` if none appeared — or the
|
||||
footer never moved off *previous* — before
|
||||
:data:`_MODE_FOOTER_SETTLE_TIMEOUT_S`.
|
||||
"""
|
||||
deadline = time.monotonic() + _MODE_FOOTER_SETTLE_TIMEOUT_S
|
||||
while True:
|
||||
mode = _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
|
||||
if mode is not None and mode != previous:
|
||||
return mode
|
||||
if time.monotonic() >= deadline:
|
||||
# Timed out: report the last mode seen so a pane that legitimately
|
||||
# stayed put is distinguished from one with no footer at all.
|
||||
return mode
|
||||
time.sleep(_MODE_FOOTER_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
def set_permission_mode(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
mode: str,
|
||||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||||
) -> str:
|
||||
"""
|
||||
Switch the running Claude terminal to *mode* by cycling shift+tab.
|
||||
|
||||
Claude Code has no non-interactive way to set a live session's mode
|
||||
(``--permission-mode`` is launch-only, ``/permissions`` is an interactive
|
||||
dialog, settings load at startup), so this drives the TUI's shift+tab
|
||||
cycle, reading the mode footer after each press. The cycle is walked
|
||||
rather than computed: its width varies with which optional modes are
|
||||
enabled, so a fixed press count could land on the wrong mode.
|
||||
|
||||
:param bridge_dir: Bridge directory path, e.g.
|
||||
``/tmp/omnigent/claude-native/<digest>``.
|
||||
:param mode: Target ``--permission-mode`` value, one of
|
||||
:data:`CYCLEABLE_PERMISSION_MODES`, e.g. ``"auto"``.
|
||||
:param timeout_s: Seconds to wait for ``tmux.json`` to be
|
||||
advertised by the runner, e.g. ``30.0``.
|
||||
:returns: The mode now rendered in the pane (== *mode*).
|
||||
:raises ValueError: If *mode* is not cycle-reachable.
|
||||
:raises RuntimeError: If the tmux target is not advertised in time,
|
||||
a ``tmux`` invocation fails, the pane never renders a mode
|
||||
footer, or the target is not reached within
|
||||
:data:`_MODE_CYCLE_MAX_PRESSES` presses (the mode is not in
|
||||
this session's cycle).
|
||||
"""
|
||||
if mode not in CYCLEABLE_PERMISSION_MODES:
|
||||
raise ValueError(
|
||||
f"permission mode {mode!r} cannot be switched on a running session; "
|
||||
f"expected one of {sorted(CYCLEABLE_PERMISSION_MODES)}"
|
||||
)
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
socket_path, tmux_target = info["socket_path"], info["tmux_target"]
|
||||
# The footer only renders once the input box is mounted; without
|
||||
# this gate a shift+tab sent mid-boot is dropped and the read below
|
||||
# reports a mode the keystroke never reached.
|
||||
_wait_for_claude_prompt_ready(socket_path, tmux_target, timeout_s=timeout_s)
|
||||
current = _read_settled_permission_mode(socket_path, tmux_target)
|
||||
if current is None:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
raise RuntimeError(
|
||||
"Claude Code did not render a permission-mode footer, so its current "
|
||||
f"mode could not be read.{_format_terminal_failure_tail(pane)}"
|
||||
)
|
||||
seen = [current]
|
||||
for _ in range(_MODE_CYCLE_MAX_PRESSES):
|
||||
if current == mode:
|
||||
return current
|
||||
# No ``-l``: tmux must interpret ``BTab`` as the shift+tab key.
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "BTab")
|
||||
settled = _read_settled_permission_mode(socket_path, tmux_target, previous=current)
|
||||
if settled is not None:
|
||||
current = settled
|
||||
seen.append(current)
|
||||
if current == mode:
|
||||
return current
|
||||
raise RuntimeError(
|
||||
f"Could not switch Claude Code to {mode!r} mode: cycled shift+tab "
|
||||
f"{_MODE_CYCLE_MAX_PRESSES} times and only reached {sorted(set(seen))}. "
|
||||
"The mode is not available in this session's cycle."
|
||||
)
|
||||
|
||||
|
||||
def confirm_dialog_if_open(bridge_dir: Path, *, hint: str) -> bool:
|
||||
"""
|
||||
Accept the *hint* dialog iff it is on screen RIGHT NOW; never blind-Enter.
|
||||
|
||||
Loop-safe building block for watchers that outlive a single injection
|
||||
(a mid-turn ``/model`` queues in Claude's composer and pops its confirm
|
||||
dialog only when the turn settles — minutes later). Unlike
|
||||
:func:`_confirm_tui_dialog` there is no timeout fallback Enter, so
|
||||
calling this every few seconds can never type into a surface that is
|
||||
not the named dialog.
|
||||
|
||||
:param bridge_dir: Bridge directory path.
|
||||
:param hint: Text the dialog renders, e.g.
|
||||
:data:`SWITCH_MODEL_DIALOG_HINT`.
|
||||
:returns: ``True`` when the dialog was on screen and confirmed.
|
||||
"""
|
||||
try:
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=1.0)
|
||||
except (RuntimeError, OSError):
|
||||
return False
|
||||
socket_path = info["socket_path"]
|
||||
tmux_target = info["tmux_target"]
|
||||
try:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
if hint not in pane:
|
||||
return False
|
||||
_confirm_and_verify_dialog_closed(socket_path, tmux_target, hint=hint)
|
||||
except (RuntimeError, OSError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _confirm_and_verify_dialog_closed(
|
||||
socket_path: str,
|
||||
tmux_target: str,
|
||||
@@ -3514,6 +3846,55 @@ def claude_pane_ready(bridge_dir: Path) -> bool:
|
||||
return _claude_prompt_rendered(pane)
|
||||
|
||||
|
||||
def _restore_occupied_input(socket_path: str, tmux_target: str) -> None:
|
||||
"""
|
||||
Dismiss a terminal-opened surface occupying Claude's input box.
|
||||
|
||||
A person can leave the composer covered from the embedded terminal —
|
||||
the ctrl+r prompt-history search, or a hand-opened ``/model`` picker
|
||||
(:data:`_OCCUPIED_INPUT_HINTS`). Keystrokes injected while one is up
|
||||
land in that surface instead of the chat input: the history search
|
||||
filters on the pasted text and its Enter replays whatever old prompt
|
||||
is selected. Each surface documents Escape as its dismissal ("Esc to
|
||||
cancel"), closing it without committing anything and restoring the
|
||||
empty input box, so the web-UI message wins the pane.
|
||||
|
||||
Escape is only sent while a hint is verifiably in the current
|
||||
capture — never blind, because on the bare composer Escape interrupts
|
||||
an in-flight turn. An empty (torn) capture means "unknown" and gets
|
||||
no Escape. A swallowed Escape is re-sent while the surface remains,
|
||||
spaced by :data:`_OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S`.
|
||||
Best-effort: a surface that outlives
|
||||
:data:`_OCCUPIED_INPUT_DISMISS_TIMEOUT_S` is left on screen and the
|
||||
caller's readiness gate or delivery verification fails loud, exactly
|
||||
as it did before this restore existed.
|
||||
|
||||
:param socket_path: Absolute path to the tmux socket.
|
||||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||||
:returns: None.
|
||||
"""
|
||||
deadline = time.monotonic() + _OCCUPIED_INPUT_DISMISS_TIMEOUT_S
|
||||
last_escape: float | None = None
|
||||
while True:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
hint = next((text for text in _OCCUPIED_INPUT_HINTS if text in pane), None)
|
||||
if hint is None:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now >= deadline:
|
||||
_logger.warning(
|
||||
"claude-native: input box still occupied (%r) after %.1fs; proceeding",
|
||||
hint,
|
||||
_OCCUPIED_INPUT_DISMISS_TIMEOUT_S,
|
||||
)
|
||||
return
|
||||
if last_escape is None or now - last_escape >= _OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S:
|
||||
_logger.info("claude-native: dismissing %r covering the input box", hint)
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Escape")
|
||||
last_escape = now
|
||||
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
def _claude_prompt_rendered(pane: str) -> bool:
|
||||
"""
|
||||
Return whether Claude Code's input prompt is rendered in a pane.
|
||||
@@ -3707,7 +4088,7 @@ def _wait_for_claude_prompt_ready(
|
||||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||||
:param timeout_s: Seconds to wait for the prompt, e.g. ``30.0``.
|
||||
:returns: None.
|
||||
:raises RuntimeError: If the prompt never renders within
|
||||
:raises ClaudePromptTimeout: If the prompt never renders within
|
||||
*timeout_s* (Claude failed to boot). The message carries a poll
|
||||
count, how many of those polls saw an empty capture, and the tail
|
||||
of the last non-empty capture the loop actually observed (see
|
||||
@@ -3744,7 +4125,7 @@ def _wait_for_claude_prompt_ready(
|
||||
# session is alive but capture-pane came back blank); non-empty captures
|
||||
# with no box point at Claude never rendering the prompt (a boot crash,
|
||||
# e.g. a ``JSON Parse error``, whose text the tail then surfaces).
|
||||
raise RuntimeError(
|
||||
raise ClaudePromptTimeout(
|
||||
f"Claude Code terminal did not become ready within {timeout_s}s "
|
||||
f"(input prompt never rendered in {polls} polls, "
|
||||
f"{empty_polls} empty captures). The message was not delivered."
|
||||
@@ -3800,7 +4181,7 @@ def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]
|
||||
:param bridge_dir: Bridge directory path.
|
||||
:param timeout_s: Seconds to wait, e.g. ``30.0``.
|
||||
:returns: ``{"socket_path": ..., "tmux_target": ...}``.
|
||||
:raises RuntimeError: If the file never appears with valid
|
||||
:raises TmuxSessionNotAdvertised: If the file never appears with valid
|
||||
``socket_path`` and ``tmux_target`` fields.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
@@ -3812,7 +4193,7 @@ def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]
|
||||
if isinstance(socket_path, str) and isinstance(tmux_target, str):
|
||||
return {"socket_path": socket_path, "tmux_target": tmux_target}
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(
|
||||
raise TmuxSessionNotAdvertised(
|
||||
"Claude terminal tmux target is not advertised yet. Wait for the "
|
||||
"terminal to launch before sending messages from the web UI."
|
||||
)
|
||||
@@ -3856,6 +4237,7 @@ def start_tool_relay(
|
||||
loop,
|
||||
policy_client=policy_client,
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
)
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
|
||||
host, port = _http_server_host_port(httpd)
|
||||
@@ -3869,6 +4251,13 @@ def start_tool_relay(
|
||||
if session_id is not None:
|
||||
relay_info["session_id"] = session_id
|
||||
_write_json_file(bridge_dir / _TOOL_RELAY_FILE, relay_info)
|
||||
# token_urlsafe's alphabet is [A-Za-z0-9_-], safe inside single quotes.
|
||||
env_path = bridge_dir / _TOOL_RELAY_ENV_FILE
|
||||
env_path.write_text(
|
||||
f"OMNIGENT_RELAY_URL='http://{host}:{port}'\nOMNIGENT_RELAY_TOKEN='{token}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(env_path, 0o600)
|
||||
thread = threading.Thread(
|
||||
target=httpd.serve_forever,
|
||||
name="claude-native-tool-relay",
|
||||
@@ -4069,6 +4458,7 @@ def _tool_relay_handler_factory(
|
||||
*,
|
||||
policy_client: httpx.AsyncClient | None = None,
|
||||
session_id: str | None = None,
|
||||
bridge_dir: Path | None = None,
|
||||
) -> type[BaseHTTPRequestHandler]:
|
||||
"""
|
||||
Create an HTTP handler class for active-turn tool calls.
|
||||
@@ -4103,7 +4493,11 @@ def _tool_relay_handler_factory(
|
||||
|
||||
:returns: None.
|
||||
"""
|
||||
if self.path not in ("/tool", "/policies/evaluate"):
|
||||
if self.path not in (
|
||||
"/tool",
|
||||
"/policies/evaluate",
|
||||
"/hook/claude/evaluate-policy",
|
||||
):
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if self.headers.get("Authorization") != f"Bearer {token}":
|
||||
@@ -4113,6 +4507,9 @@ def _tool_relay_handler_factory(
|
||||
if payload is None:
|
||||
self.send_error(HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if self.path == "/hook/claude/evaluate-policy":
|
||||
self._handle_hook_evaluate(payload)
|
||||
return
|
||||
if self.path == "/policies/evaluate":
|
||||
self._handle_policy_evaluate(payload)
|
||||
return
|
||||
@@ -4125,6 +4522,89 @@ def _tool_relay_handler_factory(
|
||||
arguments = {}
|
||||
self._send_json(_run_relay_tool(tool_executor, loop, name, arguments))
|
||||
|
||||
def _respond_hook_output(self, output: dict[str, object] | None) -> None:
|
||||
"""Answer a hook-evaluate request with final hook output JSON.
|
||||
|
||||
:param output: Hook output dict, or ``None`` for "no opinion"
|
||||
(empty body — Claude proceeds).
|
||||
"""
|
||||
raw = b"" if output is None else json.dumps(output).encode("utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
if raw:
|
||||
self.wfile.write(raw)
|
||||
|
||||
def _handle_hook_evaluate(self, payload: _JsonObject) -> None:
|
||||
"""Serve one Claude policy hook event end to end.
|
||||
|
||||
The hook subprocess is a bare curl: this endpoint does the
|
||||
payload→EvaluationRequest transform, the upstream evaluate
|
||||
call, and the EvaluationResponse→hook-output transform, so the
|
||||
blocking hook path pays no interpreter spawn. Responses are
|
||||
always 200; enforcement failures are expressed as fail-closed
|
||||
hook output.
|
||||
"""
|
||||
# Heavy policy imports stay off this module's import path (hook
|
||||
# subprocesses import it); the relay runs inside the runner
|
||||
# process where these modules are already loaded.
|
||||
from omnigent.native_policy_hook import (
|
||||
evaluation_response_to_hook_output,
|
||||
fail_closed_hook_output,
|
||||
hook_payload_to_evaluation_request,
|
||||
)
|
||||
|
||||
raw_event = payload.get("hook_event_name")
|
||||
hook_event = raw_event if isinstance(raw_event, str) else ""
|
||||
if policy_client is None or session_id is None:
|
||||
self._respond_hook_output(None)
|
||||
return
|
||||
eval_request = hook_payload_to_evaluation_request(hook_event, payload)
|
||||
if eval_request is None:
|
||||
self._respond_hook_output(None)
|
||||
return
|
||||
context = eval_request["event"]["context"]
|
||||
context["harness"] = "claude-native"
|
||||
if bridge_dir is not None:
|
||||
status_model = read_claude_status_model(bridge_dir)
|
||||
if status_model:
|
||||
context["model"] = status_model
|
||||
# Stable re-attach id: a retried long-poll reattaches to the
|
||||
# same parked ASK instead of raising a second approval card.
|
||||
request_body = {
|
||||
**eval_request,
|
||||
"_omnigent_elicitation_id": f"elicit_evaluate_{secrets.token_hex(16)}",
|
||||
}
|
||||
import urllib.parse as _up
|
||||
|
||||
url = f"/v1/sessions/{_up.quote(session_id, safe='')}/policies/evaluate"
|
||||
verdict: object = None
|
||||
last_error: str | None = None
|
||||
for attempt in range(3):
|
||||
if attempt:
|
||||
time.sleep(0.4)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
policy_client.post(url, json=request_body), loop
|
||||
)
|
||||
try:
|
||||
resp = future.result(timeout=86400.0)
|
||||
except Exception as exc: # noqa: BLE001 — shaped fail-closed below
|
||||
last_error = str(exc).strip() or type(exc).__name__
|
||||
continue
|
||||
if resp.status_code != HTTPStatus.OK:
|
||||
last_error = f"server returned HTTP {resp.status_code}"
|
||||
continue
|
||||
try:
|
||||
verdict = json.loads(resp.content)
|
||||
except (ValueError, TypeError):
|
||||
last_error = "malformed EvaluationResponse body"
|
||||
break
|
||||
if not isinstance(verdict, dict) or not verdict.get("result"):
|
||||
self._respond_hook_output(fail_closed_hook_output(hook_event, last_error))
|
||||
return
|
||||
self._respond_hook_output(evaluation_response_to_hook_output(hook_event, verdict))
|
||||
|
||||
def _handle_policy_evaluate(self, payload: _JsonObject) -> None:
|
||||
if policy_client is None or session_id is None:
|
||||
self.send_error(HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
@@ -4692,6 +5172,14 @@ def _build_tools(config: _JsonObject) -> tuple[dict[str, Tool], Callable[[], Non
|
||||
:returns: ``(tools, close_tools)`` where ``close_tools``
|
||||
releases any helper processes.
|
||||
"""
|
||||
# Imported here, not at module top: this drags the tools/spec/pydantic
|
||||
# graph (~300 ms of interpreter startup), and this module is on the
|
||||
# import path of every per-chunk/per-tool-call Claude hook subprocess.
|
||||
# Only the bridge MCP server (launch path) ever builds these tools.
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.os_env import create_os_environment
|
||||
from omnigent.tools.builtins.os_env import build_os_env_tools
|
||||
|
||||
workspace_raw = config.get("workspace")
|
||||
workspace = Path(workspace_raw) if isinstance(workspace_raw, str) and workspace_raw else None
|
||||
os_env: OSEnvironment | None = None
|
||||
@@ -4766,6 +5254,32 @@ def _model_from_transcript_entry(entry: _JsonObject) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _custom_title_from_transcript_entry(entry: _JsonObject) -> str | None:
|
||||
"""
|
||||
Return ``customTitle`` from a ``custom-title`` transcript record.
|
||||
|
||||
Claude Code appends this metadata record when the operator renames
|
||||
the session from the pane (``/rename``). It carries no ``message``,
|
||||
so it renders no conversation item; the forwarder mirrors it onto the
|
||||
Omnigent session title instead.
|
||||
|
||||
Only the explicit user title is read. Claude also writes an
|
||||
``aiTitle`` record holding its own generated summary, which is
|
||||
ignored here because Omnigent runs its own background titler and two
|
||||
auto-titlers would fight over one field.
|
||||
|
||||
:param entry: One decoded transcript JSONL record.
|
||||
:returns: The operator-chosen title, e.g. ``"auth-refactor"``, or
|
||||
``None`` for other record types and blank values.
|
||||
"""
|
||||
if entry.get("type") != "custom-title":
|
||||
return None
|
||||
title = entry.get("customTitle")
|
||||
if isinstance(title, str) and title.strip():
|
||||
return title
|
||||
return None
|
||||
|
||||
|
||||
def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
|
||||
"""
|
||||
Read the most recent statusLine snapshot from ``context.json``.
|
||||
@@ -4800,6 +5314,31 @@ def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
|
||||
return parsed
|
||||
|
||||
|
||||
def read_permission_mode(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the permission mode currently rendered in the Claude pane.
|
||||
|
||||
Non-blocking and best-effort: the forwarder calls this every poll so an
|
||||
in-pane shift+tab switch reaches the web UI, which otherwise never sees it
|
||||
(only UI-driven switches stamp the mode label). Returns ``None`` when the
|
||||
terminal isn't up or the pane shows no mode footer, so a caller can treat
|
||||
"unknown" as "no fresh observation" rather than a change.
|
||||
|
||||
:param bridge_dir: Bridge directory path, e.g.
|
||||
``/tmp/omnigent/claude-native/<digest>``.
|
||||
:returns: The ``--permission-mode`` value rendered in the pane, e.g.
|
||||
``"auto"``, or ``None`` when it cannot be determined.
|
||||
"""
|
||||
payload = _read_json_file(bridge_dir / _TMUX_FILE)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
socket_path = payload.get("socket_path")
|
||||
tmux_target = payload.get("tmux_target")
|
||||
if not isinstance(socket_path, str) or not isinstance(tmux_target, str):
|
||||
return None
|
||||
return _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
|
||||
|
||||
|
||||
def read_claude_status_model(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the active model id from the statusLine snapshot ``context.json``.
|
||||
|
||||
@@ -20,6 +20,7 @@ from omnigent._native_post_delivery import (
|
||||
append_dead_letter,
|
||||
post_external_session_status,
|
||||
post_may_have_been_delivered,
|
||||
post_session_events_batch,
|
||||
)
|
||||
from omnigent.claude_native_bridge import (
|
||||
BRIDGE_ID_LABEL_KEY,
|
||||
@@ -36,6 +37,7 @@ from omnigent.claude_native_bridge import (
|
||||
read_hook_events_from_offset,
|
||||
read_hook_events_since_with_position,
|
||||
read_message_deltas_from_offset,
|
||||
read_permission_mode,
|
||||
read_transcript_items_from_offset,
|
||||
read_transcript_items_since_with_position,
|
||||
read_transcript_path,
|
||||
@@ -45,6 +47,7 @@ from omnigent.claude_native_bridge import (
|
||||
write_active_session_id,
|
||||
)
|
||||
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
|
||||
from omnigent.claude_native_status import sync_raw_status_context
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.reasoning_effort import CLAUDE_EFFORTS, EFFORT_CLEAR_VALUES
|
||||
|
||||
@@ -81,6 +84,11 @@ _SUBAGENT_IDLE_QUIESCENCE_S = 5.0
|
||||
# ``agent-<id>.jsonl`` transcript.
|
||||
_SUBAGENT_META_GLOB = "agent-*.meta.json"
|
||||
_DEFAULT_POLL_INTERVAL_S = 0.25
|
||||
# Minimum spacing between permission-mode pane reads. Unlike the model mirror
|
||||
# (which reads a JSON file), this spawns a ``tmux capture-pane`` subprocess, so
|
||||
# it runs well below the poll interval; a mode switch is a human action and 2s
|
||||
# of lag is imperceptible.
|
||||
_PERMISSION_MODE_POLL_INTERVAL_S = 2.0
|
||||
# Hard ceiling on one poll iteration of the forward loop. A silently stalled
|
||||
# await anywhere in the pipeline used to stop mirroring, status and the busy
|
||||
# signal forever; the deadline cancels the stall (the traceback names it) and
|
||||
@@ -489,16 +497,15 @@ class _ForwardDedupeState:
|
||||
:param usage: Last ``message.usage`` snapshot POSTed via
|
||||
``external_session_usage``, or ``None`` if none yet.
|
||||
:param context_window: Last context-window POSTed, or ``None``.
|
||||
:param observed_model: Last tier alias seen in the transcript,
|
||||
sticky across polls (the incremental window often carries no
|
||||
fresh ``message.model``), e.g. ``"opus"``. ``None`` until first
|
||||
seen.
|
||||
:param posted_model: Last tier alias POSTed via
|
||||
``external_model_change``. Seeded from the first observation
|
||||
WITHOUT a POST so a passive spawn default never overwrites a
|
||||
pending silent model handoff; only a later in-TUI switch is
|
||||
mirrored. Left behind ``observed_model`` on a failed POST so the
|
||||
next poll retries. ``None`` until the first observation.
|
||||
:param observed_model: Last VERBATIM model seen (statusLine or
|
||||
transcript), sticky across polls (the incremental window often
|
||||
carries no fresh ``message.model``), e.g.
|
||||
``"claude-opus-4-8[1m]"``. ``None`` until first seen.
|
||||
:param posted_model: Last verbatim model POSTed via
|
||||
``external_model_change``. Every observation posts — the first
|
||||
one is the launch report that seeds the session's
|
||||
``reported_model``. Left behind ``observed_model`` on a failed
|
||||
POST so the next poll retries. ``None`` until the first post.
|
||||
:param posted_cost: Last DISPLAY cost (USD) POSTed as
|
||||
``cumulative_cost_usd`` — the statusLine total ``S`` verbatim.
|
||||
``None`` until the first cost post. Used to dedupe so a steady
|
||||
@@ -509,6 +516,15 @@ class _ForwardDedupeState:
|
||||
from ``posted_cost`` because it advances mid-turn (with in-flight
|
||||
sub-agent spend) while ``S`` stays frozen. ``None`` until first
|
||||
post.
|
||||
:param observed_title: Last ``custom-title`` seen in the transcript,
|
||||
sticky across polls, e.g. ``"auth-refactor"``. ``None`` until the
|
||||
operator runs ``/rename``.
|
||||
:param posted_title: Last title POSTed via
|
||||
``external_session_title``. Unlike ``posted_model`` this is NOT
|
||||
seeded without a POST — a ``custom-title`` record only exists
|
||||
because the operator renamed the session, so the first
|
||||
observation is a real change worth mirroring. Left behind
|
||||
``observed_title`` on a failed POST so the next poll retries.
|
||||
:param recorded_token_usage: Last token counters recorded on a
|
||||
``claude_native.usage`` span as ``gen_ai.usage.*``. Deduped
|
||||
separately from ``usage`` because that snapshot also moves on
|
||||
@@ -522,6 +538,8 @@ class _ForwardDedupeState:
|
||||
recorded_token_usage: dict[str, int] | None = None
|
||||
observed_model: str | None = None
|
||||
posted_model: str | None = None
|
||||
observed_title: str | None = None
|
||||
posted_title: str | None = None
|
||||
# Last DISPLAY cost (USD) POSTed as ``cumulative_cost_usd`` — the
|
||||
# statusLine total ``S`` verbatim (matches /cost in the Claude TUI).
|
||||
# Kept to suppress duplicate posts when S hasn't advanced.
|
||||
@@ -531,6 +549,13 @@ class _ForwardDedupeState:
|
||||
# sub-agent spend so the gate can block mid-turn. Separate baseline
|
||||
# because it can advance while ``posted_cost`` (S) is frozen.
|
||||
posted_policy_cost: float | None = None
|
||||
# Last permission mode POSTed as ``external_permission_mode_change`` —
|
||||
# mirrors the launch mode and any in-pane shift+tab switch, neither of
|
||||
# which the web UI can observe on its own.
|
||||
posted_permission_mode: str | None = None
|
||||
# Monotonic deadline before which the next pane read is skipped, so the
|
||||
# subprocess spawn runs at _PERMISSION_MODE_POLL_INTERVAL_S, not every poll.
|
||||
permission_mode_next_read: float = 0.0
|
||||
# Turn-settle latch driving the scheduled-wake boundary. The Stop edge
|
||||
# records the ended turn's id as PENDING; it activates (moves to
|
||||
# ``settled_response_id``) only once a fully-consumed transcript batch
|
||||
@@ -782,6 +807,9 @@ async def forward_claude_transcript_to_session(
|
||||
# the per-poll cost reconciliation from re-parsing unchanged transcripts.
|
||||
# Reset on /clear and /fork rotations alongside ``dedupe``.
|
||||
cost_cache: dict[Path, _TranscriptCostCacheEntry] = {}
|
||||
# (mtime_ns, size) of the statusLine shim's raw capture last normalized
|
||||
# into context.json (see claude_native_status.sync_raw_status_context).
|
||||
status_raw_sig: tuple[int, int] | None = None
|
||||
# Per-process latch: once we PATCH the conversation with the
|
||||
# Claude-native session id, never PATCH again. Persists for the
|
||||
# lifetime of the forwarder task; the server's idempotence handles
|
||||
@@ -901,6 +929,9 @@ async def forward_claude_transcript_to_session(
|
||||
session_id=current_session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
)
|
||||
# Normalize the statusLine shim's raw capture into
|
||||
# context.json (one stat when nothing changed).
|
||||
status_raw_sig = sync_raw_status_context(bridge_dir, status_raw_sig)
|
||||
transcript_path = read_transcript_path(bridge_dir)
|
||||
if transcript_path is not None:
|
||||
state = await _ensure_state_for_transcript(
|
||||
@@ -994,6 +1025,14 @@ async def forward_claude_transcript_to_session(
|
||||
bridge_dir=bridge_dir,
|
||||
dedupe=dedupe,
|
||||
)
|
||||
# Same rationale for the permission mode: a shift+tab in
|
||||
# the pane emits no event, so poll the footer.
|
||||
await _forward_permission_mode_from_pane(
|
||||
client=client,
|
||||
session_id=current_session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
dedupe=dedupe,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except TimeoutError:
|
||||
@@ -3554,19 +3593,27 @@ async def _forward_available_items(
|
||||
_http_status_for_log(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
# Mirror a TUI-side `/model` switch to the web picker. The transcript
|
||||
# records the resolved concrete id (e.g. "claude-opus-4-8"); collapse
|
||||
# it to the picker's tier alias. This transcript-derived observation
|
||||
# only fires when a turn produces a fresh ``message.model``, so it lags
|
||||
# an in-pane switch by one turn — the per-poll statusLine sync
|
||||
# (:func:`_forward_model_from_status`) is the primary, low-latency
|
||||
# source; this stays as a fallback for cold-resume before the first
|
||||
# statusLine render. Both share ``dedupe`` so neither double-posts.
|
||||
# Report the transcript's model verbatim. This transcript-derived
|
||||
# observation only fires when a turn produces a fresh
|
||||
# ``message.model``, so it lags an in-pane switch by one turn — the
|
||||
# per-poll statusLine sync (:func:`_forward_model_from_status`) is the
|
||||
# primary, low-latency source; this stays as a fallback for cold-resume
|
||||
# before the first statusLine render. Both share ``dedupe`` so neither
|
||||
# double-posts.
|
||||
await _post_model_change_if_new(
|
||||
client,
|
||||
session_id=session_id,
|
||||
dedupe=dedupe,
|
||||
alias=_model_alias_for(result.latest_model),
|
||||
model=result.latest_model,
|
||||
)
|
||||
# Mirror a TUI-side `/rename` to the web session list. Claude writes the
|
||||
# operator's title as a `custom-title` metadata record, which renders no
|
||||
# conversation item, so this is the only path that surfaces it.
|
||||
await _post_title_change_if_new(
|
||||
client,
|
||||
session_id=session_id,
|
||||
dedupe=dedupe,
|
||||
title=result.latest_custom_title,
|
||||
)
|
||||
return updated
|
||||
|
||||
@@ -3975,19 +4022,129 @@ async def _post_external_output_text_delta(
|
||||
"""
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={
|
||||
"type": "external_output_text_delta",
|
||||
"data": {
|
||||
"delta": delta.delta,
|
||||
"message_id": delta.message_id,
|
||||
"index": delta.index,
|
||||
"final": delta.final,
|
||||
},
|
||||
},
|
||||
json=_output_text_delta_event(delta),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _output_text_delta_event(delta: ClaudeMessageDelta) -> dict[str, object]:
|
||||
"""
|
||||
Build the ``external_output_text_delta`` event body for one chunk.
|
||||
|
||||
:param delta: Parsed streamed chunk.
|
||||
:returns: The event body a single or batched post carries.
|
||||
"""
|
||||
return {
|
||||
"type": "external_output_text_delta",
|
||||
"data": {
|
||||
"delta": delta.delta,
|
||||
"message_id": delta.message_id,
|
||||
"index": delta.index,
|
||||
"final": delta.final,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _log_dropped_delta(
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
delta: ClaudeMessageDelta,
|
||||
http_status: int | None = None,
|
||||
error_type: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Note one streamed chunk that never reached the server.
|
||||
|
||||
Records the status and the exception's class, never a rendered
|
||||
exception or server message: an httpx error's text carries the request
|
||||
it was made with, and this log is not the place to spill anything that
|
||||
travelled in a header.
|
||||
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param bridge_dir: Native Claude bridge directory.
|
||||
:param delta: The chunk that was dropped.
|
||||
:param http_status: Status the server reported, when it responded.
|
||||
:param error_type: Exception class name, for a transport failure.
|
||||
:returns: None.
|
||||
"""
|
||||
_logger.debug(
|
||||
"Dropping Claude streamed delta after HTTP failure; session=%s "
|
||||
"bridge_dir=%s message_id=%s index=%s http_status=%s error_type=%s",
|
||||
session_id,
|
||||
bridge_dir,
|
||||
delta.message_id,
|
||||
delta.index,
|
||||
http_status,
|
||||
error_type,
|
||||
)
|
||||
|
||||
|
||||
async def _forward_delta_run(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
deltas: list[ClaudeMessageDelta],
|
||||
) -> None:
|
||||
"""
|
||||
Publish a poll's worth of streamed chunks in as few round trips as possible.
|
||||
|
||||
One request for the whole run when the server serves the batch route,
|
||||
else one per chunk as before. Deltas are best-effort live preview, so
|
||||
a failure is logged and dropped (the authoritative final text still
|
||||
arrives via ``external_conversation_item``) — never retried, so a
|
||||
transient blip can't wedge the tail. That is also why the batch runs
|
||||
with ``on_error="continue"``: one rejected chunk must not shadow the
|
||||
rest of the run.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param bridge_dir: Native Claude bridge directory (logging only).
|
||||
:param deltas: The new chunks to publish, in order.
|
||||
:returns: None.
|
||||
"""
|
||||
try:
|
||||
outcomes = await post_session_events_batch(
|
||||
client,
|
||||
session_id=session_id,
|
||||
events=[_output_text_delta_event(delta) for delta in deltas],
|
||||
on_error="continue",
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
for delta in deltas:
|
||||
_log_dropped_delta(
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
delta=delta,
|
||||
http_status=_http_status_for_log(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return
|
||||
if outcomes is not None:
|
||||
for delta, outcome in zip(deltas, outcomes, strict=False):
|
||||
if not outcome.delivered:
|
||||
_log_dropped_delta(
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
delta=delta,
|
||||
http_status=outcome.status,
|
||||
)
|
||||
return
|
||||
# Server predates the batch route: post one at a time.
|
||||
for delta in deltas:
|
||||
try:
|
||||
await _post_external_output_text_delta(client, session_id=session_id, delta=delta)
|
||||
except httpx.HTTPError as exc:
|
||||
_log_dropped_delta(
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
delta=delta,
|
||||
http_status=_http_status_for_log(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
async def _forward_available_deltas(
|
||||
*,
|
||||
client: httpx.AsyncClient,
|
||||
@@ -4030,6 +4187,7 @@ async def _forward_available_deltas(
|
||||
)
|
||||
if result.byte_offset == state.byte_offset and not result.deltas:
|
||||
return state
|
||||
fresh: list[ClaudeMessageDelta] = []
|
||||
for delta in result.deltas:
|
||||
key = (delta.message_id, delta.index)
|
||||
if key in seen_keys:
|
||||
@@ -4040,18 +4198,11 @@ async def _forward_available_deltas(
|
||||
# limit.
|
||||
while len(seen_keys) > _MAX_SEEN_DELTA_KEYS:
|
||||
del seen_keys[next(iter(seen_keys))]
|
||||
try:
|
||||
await _post_external_output_text_delta(client, session_id=session_id, delta=delta)
|
||||
except httpx.HTTPError as exc:
|
||||
_logger.debug(
|
||||
"Dropping Claude streamed delta after HTTP failure; session=%s "
|
||||
"bridge_dir=%s message_id=%s index=%s http_status=%s",
|
||||
session_id,
|
||||
bridge_dir,
|
||||
delta.message_id,
|
||||
delta.index,
|
||||
_http_status_for_log(exc),
|
||||
)
|
||||
fresh.append(delta)
|
||||
if fresh:
|
||||
await _forward_delta_run(
|
||||
client, session_id=session_id, bridge_dir=bridge_dir, deltas=fresh
|
||||
)
|
||||
updated = DeltaForwardState(byte_offset=result.byte_offset)
|
||||
await _write_delta_forward_state_async(bridge_dir, updated)
|
||||
return updated
|
||||
@@ -4150,38 +4301,81 @@ def _gen_ai_usage_tokens(usage: Mapping[str, float | str] | None) -> dict[str, i
|
||||
return tokens
|
||||
|
||||
|
||||
def _model_alias_for(model: str | None) -> str | None:
|
||||
async def _post_external_permission_mode_change(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""
|
||||
Collapse a concrete Claude model id to the picker's tier alias.
|
||||
Post one ``external_permission_mode_change`` event to the Sessions API.
|
||||
|
||||
The web model picker speaks Claude Code's version-agnostic aliases
|
||||
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
|
||||
extra concrete-id slot ``"sonnet_5"`` (see
|
||||
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
|
||||
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
|
||||
transcript records the resolved concrete id (e.g.
|
||||
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
|
||||
Mapping to the tier keeps the mirrored value in the picker's
|
||||
vocabulary and makes a web→TUI round-trip a no-op. The older Sonnet
|
||||
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias — it is the
|
||||
default that row is bound to.
|
||||
Lets the web mode picker reflect a shift+tab switch made inside the Claude
|
||||
Code terminal, which Omnigent has no other way to observe.
|
||||
|
||||
:param model: Concrete model id from the transcript, e.g.
|
||||
``"claude-opus-4-8"``; ``None`` when none observed yet.
|
||||
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
|
||||
``"haiku"`` when the id carries a known tier token, else ``None``
|
||||
(the caller skips the post rather than surface an id the picker
|
||||
can't render).
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id, e.g. ``"conv_abc123"``.
|
||||
:param mode: Permission mode the pane now shows, e.g. ``"auto"``.
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
lowered = model.lower()
|
||||
if "sonnet-5" in lowered or "sonnet_5" in lowered:
|
||||
return "sonnet_5"
|
||||
for tier in ("fable", "opus", "sonnet", "haiku"):
|
||||
if tier in lowered:
|
||||
return tier
|
||||
return None
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_permission_mode_change", "data": {"permission_mode": mode}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _forward_permission_mode_from_pane(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
dedupe: _ForwardDedupeState,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror the pane's permission-mode footer to the session label each poll.
|
||||
|
||||
A shift+tab pressed inside the TUI produces no event Omnigent can see, so
|
||||
without this the web picker shows a stale mode until the next UI-driven
|
||||
switch. Polling the footer is the only signal available: Claude Code emits
|
||||
nothing on a mode change, and hook payloads only arrive on tool use.
|
||||
|
||||
The launch mode is posted too, not just later switches: a session started
|
||||
in manual mode carries no ``--permission-mode`` arg and no mode label, so
|
||||
with nothing posted the web picker has no mode to render and hides itself.
|
||||
Best-effort and idempotent — the server ignores a mode equal to the stored
|
||||
label, an unchanged mode or unreadable pane is a no-op, and a failed POST
|
||||
is retried next poll.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param bridge_dir: Native Claude bridge directory.
|
||||
:param dedupe: Shared per-session dedupe state; mutated in place.
|
||||
"""
|
||||
# Throttled: this spawns a tmux subprocess, unlike the file-backed model
|
||||
# mirror that shares this poll loop.
|
||||
now = time.monotonic()
|
||||
if now < dedupe.permission_mode_next_read:
|
||||
return
|
||||
dedupe.permission_mode_next_read = now + _PERMISSION_MODE_POLL_INTERVAL_S
|
||||
mode = await asyncio.to_thread(read_permission_mode, bridge_dir)
|
||||
if mode is None or mode == dedupe.posted_permission_mode:
|
||||
return
|
||||
try:
|
||||
await _post_external_permission_mode_change(
|
||||
client,
|
||||
session_id=session_id,
|
||||
mode=mode,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
_logger.debug(
|
||||
"external_permission_mode_change post failed; session=%s mode=%s",
|
||||
session_id,
|
||||
mode,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
dedupe.posted_permission_mode = mode
|
||||
|
||||
|
||||
async def _post_external_model_change(
|
||||
@@ -4193,13 +4387,15 @@ async def _post_external_model_change(
|
||||
"""
|
||||
Post one ``external_model_change`` event to the Sessions API.
|
||||
|
||||
Lets the web model picker reflect a model switch made inside the
|
||||
Claude Code terminal (a ``/model`` command or the in-TUI picker).
|
||||
Reports the model the pane is actually on — the launch's own model
|
||||
included — so every surface renders the harness's truth.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param model: Tier alias the session is now on, e.g. ``"opus"``.
|
||||
:param model: The harness's VERBATIM model, e.g.
|
||||
``"claude-opus-4-8[1m]"`` — never collapsed to a picker alias
|
||||
(a family word claims a generation the pane may not be on).
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
resp = await client.post(
|
||||
@@ -4209,45 +4405,121 @@ async def _post_external_model_change(
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _post_external_session_title(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
title: str,
|
||||
) -> None:
|
||||
"""
|
||||
Post one ``external_session_title`` event to the Sessions API.
|
||||
|
||||
Mirrors a ``/rename`` typed in the Claude Code pane onto the Omnigent
|
||||
session title so the web session list stops showing the stale
|
||||
auto-generated one.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param title: Operator-chosen title, e.g. ``"auth-refactor"``.
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_session_title", "data": {"title": title}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _post_title_change_if_new(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
dedupe: _ForwardDedupeState,
|
||||
title: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror an observed ``/rename`` title to the session, deduped.
|
||||
|
||||
Unlike :func:`_post_model_change_if_new`, the FIRST observation is
|
||||
posted rather than used to seed the baseline silently: a
|
||||
``custom-title`` record exists only because the operator ran
|
||||
``/rename``, so there is no passive spawn default to protect.
|
||||
|
||||
A steady-state poll reads only records past its byte cursor, so the
|
||||
dedupe is not for the ordinary case — it covers the cursor rewind /
|
||||
restart path that re-reads an already-posted record, and it is what
|
||||
makes the retry below safe to attempt on every poll.
|
||||
|
||||
Best-effort: a failed POST leaves ``posted_title`` behind
|
||||
``observed_title`` so the next poll retries. ``observed_title`` is
|
||||
sticky for exactly this reason — the retry must survive polls whose
|
||||
own window carries no rename.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param dedupe: Shared per-session dedupe state; mutated in place.
|
||||
:param title: Title just observed, or ``None`` when this poll's
|
||||
window carried no ``custom-title`` record. ``observed_title`` is
|
||||
sticky, so ``None`` does not clear it — a previously-observed but
|
||||
unposted title is still retried here.
|
||||
"""
|
||||
if title is not None:
|
||||
dedupe.observed_title = title
|
||||
if dedupe.observed_title is None or dedupe.observed_title == dedupe.posted_title:
|
||||
return
|
||||
try:
|
||||
await _post_external_session_title(
|
||||
client,
|
||||
session_id=session_id,
|
||||
title=dedupe.observed_title,
|
||||
)
|
||||
dedupe.posted_title = dedupe.observed_title
|
||||
except httpx.HTTPError:
|
||||
# Leave posted_title behind observed_title so the next poll retries.
|
||||
_logger.warning(
|
||||
"Failed to mirror /rename to Omnigent session=%s; the web session "
|
||||
"list may show a stale title until the next poll",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _post_model_change_if_new(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
dedupe: _ForwardDedupeState,
|
||||
alias: str | None,
|
||||
model: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror an observed model tier alias to ``model_override``, deduped.
|
||||
Report the observed model to ``reported_model``, verbatim and deduped.
|
||||
|
||||
Shared by the transcript-driven path (:func:`_forward_available_items`)
|
||||
and the statusLine-driven per-poll path
|
||||
(:func:`_forward_model_from_status`). The FIRST observation is the
|
||||
session's spawn default, not a switch, so it seeds the dedupe baseline
|
||||
WITHOUT posting (posting it could clobber a pending silent model
|
||||
handoff). Every later change posts ``external_model_change``. Both
|
||||
callers pass the same ``dedupe`` so whichever observes a switch first
|
||||
posts it and the other no-ops. Best-effort: a failed POST leaves
|
||||
``posted_model`` behind ``observed_model`` so the next poll retries.
|
||||
(:func:`_forward_model_from_status`). EVERY observation posts — the
|
||||
first one is the launch report that seeds the session's
|
||||
``reported_model``, so surfaces show the pane's truth within seconds
|
||||
of spawn; the server dedupes by equality, so a steady model costs one
|
||||
POST total. Both callers pass the same ``dedupe`` so whichever
|
||||
observes a change first posts it and the other no-ops. Best-effort: a
|
||||
failed POST leaves ``posted_model`` behind ``observed_model`` so the
|
||||
next poll retries.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param dedupe: Shared per-session dedupe state; mutated in place.
|
||||
:param alias: Tier alias just observed (``"opus"`` / ``"sonnet"`` /
|
||||
…), or ``None`` when this source carried no recognizable model on
|
||||
this poll. ``observed_model`` is sticky across polls, so passing
|
||||
``None`` does NOT clear it — it just means "no fresh observation,"
|
||||
and a previously-observed-but-unposted model is still reconciled
|
||||
(retried) here.
|
||||
:param model: The harness's verbatim model just observed (e.g.
|
||||
``"claude-opus-4-8[1m]"``), or ``None`` when this source carried
|
||||
no model on this poll. ``observed_model`` is sticky across
|
||||
polls, so passing ``None`` does NOT clear it — it just means "no
|
||||
fresh observation," and a previously-observed-but-unposted model
|
||||
is still reconciled (retried) here.
|
||||
"""
|
||||
if alias is not None:
|
||||
dedupe.observed_model = alias
|
||||
if model is not None:
|
||||
dedupe.observed_model = model
|
||||
if dedupe.observed_model is None or dedupe.observed_model == dedupe.posted_model:
|
||||
return
|
||||
if dedupe.posted_model is None:
|
||||
# First observation = the spawn default; seed the baseline without
|
||||
# posting so it can't clobber a pending silent model handoff.
|
||||
dedupe.posted_model = dedupe.observed_model
|
||||
return
|
||||
try:
|
||||
await _post_external_model_change(
|
||||
client,
|
||||
@@ -4273,7 +4545,7 @@ async def _forward_model_from_status(
|
||||
dedupe: _ForwardDedupeState,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror the statusLine-reported active model to ``model_override`` each poll.
|
||||
Report the statusLine's active model to ``reported_model`` each poll.
|
||||
|
||||
Claude Code rewrites the statusLine stdin on every TUI render — including
|
||||
right after an in-pane ``/model`` switch, BEFORE the next turn runs. The
|
||||
@@ -4285,8 +4557,9 @@ async def _forward_model_from_status(
|
||||
turn later, which is what happened when the model was derived solely
|
||||
from the next turn's transcript ``message.model``.
|
||||
|
||||
Best-effort and idempotent: shares ``dedupe`` with the transcript path,
|
||||
so a no-op when the model is unchanged.
|
||||
The value posts VERBATIM — the harness's own spelling, never collapsed
|
||||
to a picker alias. Best-effort and idempotent: shares ``dedupe`` with
|
||||
the transcript path, so a no-op when the model is unchanged.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
@@ -4297,12 +4570,11 @@ async def _forward_model_from_status(
|
||||
if status_state is None:
|
||||
return
|
||||
model = status_state.get("model")
|
||||
alias = _model_alias_for(model if isinstance(model, str) else None)
|
||||
await _post_model_change_if_new(
|
||||
client,
|
||||
session_id=session_id,
|
||||
dedupe=dedupe,
|
||||
alias=alias,
|
||||
model=model.strip() if isinstance(model, str) and model.strip() else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from omnigent.claude_native_bridge import (
|
||||
BRIDGE_ID_LABEL_KEY,
|
||||
@@ -28,17 +27,13 @@ from omnigent.claude_native_bridge import (
|
||||
url_component,
|
||||
write_active_session_id,
|
||||
)
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.native_policy_hook import (
|
||||
_is_login_redirect_or_unauthorized,
|
||||
evaluation_response_to_hook_output,
|
||||
fail_closed_hook_output,
|
||||
hook_payload_to_evaluation_request,
|
||||
policy_hook_reauth,
|
||||
post_evaluate_with_retry,
|
||||
read_relay_policy_config,
|
||||
relay_policy_evaluate_url,
|
||||
)
|
||||
|
||||
# The observer path (the default, most frequent invocation — Claude blocks
|
||||
# on it per Stop/UserPromptSubmit/TaskCreated/...) must not pay the
|
||||
# httpx/policy import cost; those are imported inside the subcommands and
|
||||
# helpers that actually speak HTTP.
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
# Client-side budget for the permission-request long-poll to AP. Held
|
||||
# at one day so the hook subprocess waits ~indefinitely for a verdict
|
||||
@@ -128,17 +123,23 @@ def _env_float(name: str, default: float) -> float:
|
||||
# down server can no longer re-POST for a day. Overridable for operators who
|
||||
# want more slack against a flaky upstream.
|
||||
_PERMISSION_MAX_CONSECUTIVE_FAILURES = max(1, _env_int("OMNIGENT_HOOK_MAX_RETRIES", 8))
|
||||
# httpx errors that mean the request never reached a live server (no response
|
||||
# was ever begun). These are unambiguous hard failures — the server is down /
|
||||
# unreachable, not holding a poll. Everything else under ``httpx.HTTPError``
|
||||
# that is not a 4xx/5xx status (RemoteProtocolError, ReadError, ReadTimeout, …)
|
||||
# means the connection was established and then severed mid-poll.
|
||||
_NEVER_CONNECTED_ERRORS = (
|
||||
httpx.ConnectError,
|
||||
httpx.ConnectTimeout,
|
||||
httpx.PoolTimeout,
|
||||
httpx.ProxyError,
|
||||
)
|
||||
|
||||
|
||||
def _never_connected_errors() -> tuple[type[Exception], ...]:
|
||||
"""httpx errors meaning the request never reached a live server.
|
||||
|
||||
No response was ever begun — unambiguous hard failures (the server is
|
||||
down / unreachable), not a held poll. Everything else under
|
||||
``httpx.HTTPError`` that is not a 4xx/5xx status (RemoteProtocolError,
|
||||
ReadError, ReadTimeout, …) means the connection was established and
|
||||
then severed mid-poll. A function, not a module constant, so the
|
||||
hook's hot observer path never imports httpx.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
return (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ProxyError)
|
||||
|
||||
|
||||
# An established connection that drops in under this many seconds is treated as
|
||||
# a flapping/crash-looping server (a hard failure), NOT a genuinely-parked poll
|
||||
# a proxy severed. Comfortably below any real idle-proxy timeout (typically
|
||||
@@ -365,6 +366,8 @@ def _rotate_session_on_clear(bridge_dir: Path) -> str | None:
|
||||
if isinstance(raw_headers, dict)
|
||||
else {}
|
||||
)
|
||||
import httpx
|
||||
|
||||
# Route the whole rotation sequence (GET old, POST /v1/sessions or /fork,
|
||||
# PATCH new, DELETE old) to the replica holding this host's tunnel: a managed
|
||||
# create/fork notifies the host inline over its pod-local tunnel, so an
|
||||
@@ -421,6 +424,8 @@ def _rotate_session_on_fork(bridge_dir: Path) -> str | None:
|
||||
if isinstance(raw_headers, dict)
|
||||
else {}
|
||||
)
|
||||
import httpx
|
||||
|
||||
# Route the whole rotation sequence (GET old, POST /v1/sessions or /fork,
|
||||
# PATCH new, DELETE old) to the replica holding this host's tunnel: a managed
|
||||
# create/fork notifies the host inline over its pod-local tunnel, so an
|
||||
@@ -509,6 +514,8 @@ def _create_clear_replacement_session(
|
||||
)
|
||||
bind_resp.raise_for_status()
|
||||
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
|
||||
terminal_id = terminal_resource_id("claude", "main")
|
||||
transfer_resp = client.post(
|
||||
(
|
||||
@@ -589,6 +596,8 @@ def _create_fork_replacement_session(
|
||||
)
|
||||
bind_resp.raise_for_status()
|
||||
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
|
||||
terminal_id = terminal_resource_id("claude", "main")
|
||||
transfer_resp = client.post(
|
||||
(
|
||||
@@ -668,7 +677,7 @@ def _post_hook_with_reattach(
|
||||
Failure classification:
|
||||
|
||||
* **Hard failure → count toward the cap.** A 5xx, or a connection that
|
||||
never established (:data:`_NEVER_CONNECTED_ERRORS`), or an established
|
||||
never established (:func:`_never_connected_errors`), or an established
|
||||
connection that dropped in under :data:`_PERMISSION_HELD_POLL_FLOOR_S`
|
||||
(a flapping/crash-looping server). This is the spin.
|
||||
* **Held-poll sever → reset the counter.** An established connection that
|
||||
@@ -721,6 +730,10 @@ def _post_hook_with_reattach(
|
||||
"_omnigent_elicitation_id": f"elicit_claude_{secrets.token_hex(16)}",
|
||||
}
|
||||
backoff_s = _PERMISSION_RETRY_INITIAL_BACKOFF_S
|
||||
import httpx
|
||||
|
||||
from omnigent.native_policy_hook import _is_login_redirect_or_unauthorized
|
||||
|
||||
timeout = httpx.Timeout(_PERMISSION_TIMEOUT_S, connect=_PERMISSION_CONNECT_TIMEOUT_S)
|
||||
# Absolute backstop: even a run of held-poll severs (which don't count
|
||||
# toward the hard-failure cap) can't loop past the day-long human-answer
|
||||
@@ -771,7 +784,7 @@ def _post_hook_with_reattach(
|
||||
# Classify by HOW it failed, not by elapsed time (a proxy severs a
|
||||
# legitimately-held poll in seconds-to-minutes, so wall-clock can't
|
||||
# tell it from a down server — #1782 Polly review).
|
||||
never_connected = isinstance(exc, _NEVER_CONNECTED_ERRORS)
|
||||
never_connected = isinstance(exc, _never_connected_errors())
|
||||
held_s = time.monotonic() - attempt_started
|
||||
# Hard failure iff the server was never reached, OR an established
|
||||
# connection dropped so fast it's a flap rather than a parked poll.
|
||||
@@ -821,6 +834,8 @@ def _main_permission_request(argv: list[str]) -> int:
|
||||
:returns: Process exit code. Returns ``0`` on transport failures so
|
||||
Claude Code falls back to its terminal prompt.
|
||||
"""
|
||||
from omnigent.native_policy_hook import policy_hook_reauth
|
||||
|
||||
args = _parse_permission_args(argv)
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
@@ -894,6 +909,8 @@ def _main_ask_user_question(argv: list[str]) -> int:
|
||||
:returns: Process exit code. Returns ``0`` on any failure so Claude Code
|
||||
falls back to its terminal TUI prompt rather than blocking.
|
||||
"""
|
||||
from omnigent.native_policy_hook import policy_hook_reauth
|
||||
|
||||
args = _parse_permission_args(argv)
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
@@ -1028,6 +1045,16 @@ def _main_evaluate_policy(argv: list[str]) -> int:
|
||||
:returns: Process exit code. Always ``0`` — blocking verdicts
|
||||
are expressed via the JSON output, not exit codes.
|
||||
"""
|
||||
from omnigent.native_policy_hook import (
|
||||
evaluation_response_to_hook_output,
|
||||
fail_closed_hook_output,
|
||||
hook_payload_to_evaluation_request,
|
||||
policy_hook_reauth,
|
||||
post_evaluate_with_retry,
|
||||
read_relay_policy_config,
|
||||
relay_policy_evaluate_url,
|
||||
)
|
||||
|
||||
args = _parse_evaluate_policy_args(argv)
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
|
||||
@@ -47,6 +47,8 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.process_logging import data_dir
|
||||
|
||||
# Env-var override for the persistent state root. Reserved for tests
|
||||
# (and for advanced users who want to put state on a non-default
|
||||
# volume). When unset, the module falls back to
|
||||
@@ -91,8 +93,8 @@ def _claude_native_state_root() -> Path:
|
||||
|
||||
Honors the :data:`_STATE_ROOT_ENV_VAR` override so tests can
|
||||
point the state tree at a per-test ``tmp_path`` without
|
||||
clobbering the user's real home directory. Production callers
|
||||
leave the env unset and get the default
|
||||
clobbering the user's state. Otherwise the root follows
|
||||
``OMNIGENT_DATA_DIR``, falling back to
|
||||
``~/.omnigent/claude-native``.
|
||||
|
||||
Lazy: created on first write, never on read (the resume / picker
|
||||
@@ -105,7 +107,7 @@ def _claude_native_state_root() -> Path:
|
||||
override = os.environ.get(_STATE_ROOT_ENV_VAR)
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path.home() / ".omnigent" / "claude-native"
|
||||
return data_dir() / "claude-native"
|
||||
|
||||
|
||||
def _state_dir_for_conversation_id(conversation_id: str) -> Path:
|
||||
|
||||
@@ -20,6 +20,93 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_CONTEXT_FILE = "context.json"
|
||||
# Raw statusLine stdin captured by the shell shim the settings now install
|
||||
# (no Python spawn on Claude's blocking statusLine path). The forwarder
|
||||
# normalizes it into ``context.json`` via :func:`sync_raw_status_context`.
|
||||
CONTEXT_RAW_FILE = "context_raw.json"
|
||||
|
||||
|
||||
def normalize_status_payload(payload: dict[str, object]) -> dict[str, object] | None:
|
||||
"""
|
||||
Extract the ``context.json`` record from a raw statusLine payload.
|
||||
|
||||
:param payload: Decoded statusLine stdin JSON from Claude Code.
|
||||
:returns: The record to persist, or ``None`` when the payload carries
|
||||
no usable ``context_window`` (nothing worth recording).
|
||||
"""
|
||||
context = payload.get("context_window")
|
||||
if not isinstance(context, dict):
|
||||
return None
|
||||
size = context.get("context_window_size")
|
||||
usage = context.get("current_usage")
|
||||
if not isinstance(size, int) or size <= 0:
|
||||
return None
|
||||
record: dict[str, object] = {"context_window_size": size}
|
||||
if isinstance(usage, dict):
|
||||
record["current_usage"] = usage
|
||||
used_pct = context.get("used_percentage")
|
||||
if isinstance(used_pct, (int, float)):
|
||||
record["used_percentage"] = used_pct
|
||||
# Claude Code's statusLine stdin carries a top-level ``cost`` block with
|
||||
# its own cumulative session billing; the forwarder reports it because
|
||||
# claude-native produces no ``response.completed`` cost events.
|
||||
cost = payload.get("cost")
|
||||
if isinstance(cost, dict):
|
||||
total_cost = cost.get("total_cost_usd")
|
||||
if (
|
||||
isinstance(total_cost, (int, float))
|
||||
and not isinstance(total_cost, bool)
|
||||
and total_cost >= 0
|
||||
):
|
||||
record["total_cost_usd"] = float(total_cost)
|
||||
# The active model, rewritten on every render — including right after an
|
||||
# in-pane ``/model`` switch — so gates see the switch before the next turn.
|
||||
model = payload.get("model")
|
||||
model_id: str | None = None
|
||||
if isinstance(model, dict):
|
||||
raw_model = model.get("id") or model.get("display_name")
|
||||
if isinstance(raw_model, str) and raw_model.strip():
|
||||
model_id = raw_model.strip()
|
||||
elif isinstance(model, str) and model.strip():
|
||||
model_id = model.strip()
|
||||
if model_id is not None:
|
||||
record["model"] = model_id
|
||||
return record
|
||||
|
||||
|
||||
def sync_raw_status_context(
|
||||
bridge_dir: Path,
|
||||
last_sig: tuple[int, int] | None,
|
||||
) -> tuple[int, int] | None:
|
||||
"""
|
||||
Normalize the shim's raw statusLine capture into ``context.json``.
|
||||
|
||||
Called from the forwarder's poll loop. Cheap when nothing changed —
|
||||
one ``stat`` against the remembered ``(mtime_ns, size)`` signature.
|
||||
|
||||
:param bridge_dir: Bridge directory shared with the statusLine shim.
|
||||
:param last_sig: Signature returned by the previous call, or ``None``.
|
||||
:returns: The new signature to carry forward (unchanged on a miss or
|
||||
an unparseable file, so the next poll retries).
|
||||
"""
|
||||
raw_path = bridge_dir / CONTEXT_RAW_FILE
|
||||
try:
|
||||
stat = raw_path.stat()
|
||||
except OSError:
|
||||
return last_sig
|
||||
sig = (stat.st_mtime_ns, stat.st_size)
|
||||
if sig == last_sig:
|
||||
return last_sig
|
||||
try:
|
||||
payload = json.loads(raw_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return last_sig
|
||||
if not isinstance(payload, dict):
|
||||
return sig
|
||||
record = normalize_status_payload(payload)
|
||||
if record is not None:
|
||||
_write_record_atomic(bridge_dir, record)
|
||||
return sig
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -67,71 +154,43 @@ def _write_context_atomic(bridge_dir: Path, payload: dict[str, object]) -> None:
|
||||
Persist the statusLine payload's context fields to ``context.json``.
|
||||
|
||||
Atomic write so the forwarder never observes a half-written file.
|
||||
Soft-fails (writes nothing) when ``context_window`` is missing or
|
||||
malformed — there's nothing useful to record.
|
||||
Soft-fails (writes nothing) when the payload carries no usable
|
||||
``context_window``. Retained for older bridge dirs whose settings
|
||||
still invoke this module; new settings install a shell shim and the
|
||||
forwarder normalizes via :func:`sync_raw_status_context`.
|
||||
|
||||
:param bridge_dir: Bridge directory shared with the forwarder.
|
||||
:param payload: Decoded statusLine stdin JSON.
|
||||
"""
|
||||
context = payload.get("context_window")
|
||||
if not isinstance(context, dict):
|
||||
record = normalize_status_payload(payload)
|
||||
if record is None:
|
||||
return
|
||||
size = context.get("context_window_size")
|
||||
usage = context.get("current_usage")
|
||||
if not isinstance(size, int) or size <= 0:
|
||||
return
|
||||
record: dict[str, object] = {"context_window_size": size}
|
||||
if isinstance(usage, dict):
|
||||
record["current_usage"] = usage
|
||||
used_pct = context.get("used_percentage")
|
||||
if isinstance(used_pct, (int, float)):
|
||||
record["used_percentage"] = used_pct
|
||||
# Claude Code's statusLine stdin carries a top-level ``cost`` block with
|
||||
# its own cumulative session billing. Capture ``total_cost_usd`` so the
|
||||
# forwarder can report it (claude-native produces no ``response.completed``
|
||||
# event, so the Omnigent relay's cost accumulation never runs for it).
|
||||
cost = payload.get("cost")
|
||||
if isinstance(cost, dict):
|
||||
total_cost = cost.get("total_cost_usd")
|
||||
if (
|
||||
isinstance(total_cost, (int, float))
|
||||
and not isinstance(total_cost, bool)
|
||||
and total_cost >= 0
|
||||
):
|
||||
record["total_cost_usd"] = float(total_cost)
|
||||
# Claude Code's statusLine stdin carries the active model as a ``model``
|
||||
# block (``{"id": "claude-opus-4-8", "display_name": "Opus"}``), rewritten
|
||||
# on every render — including right after an in-pane ``/model`` switch.
|
||||
# Capture the concrete id so the forwarder can mirror the switch to
|
||||
# ``model_override`` on the next poll, before the user's next message,
|
||||
# rather than waiting for the next turn's transcript to reveal the model
|
||||
# (which lagged model-gated policies by one turn). Defensive about the
|
||||
# shape: accept a ``{id|display_name}`` dict or a bare string.
|
||||
model = payload.get("model")
|
||||
model_id: str | None = None
|
||||
if isinstance(model, dict):
|
||||
raw_model = model.get("id") or model.get("display_name")
|
||||
if isinstance(raw_model, str) and raw_model.strip():
|
||||
model_id = raw_model.strip()
|
||||
elif isinstance(model, str) and model.strip():
|
||||
model_id = model.strip()
|
||||
if model_id is not None:
|
||||
record["model"] = model_id
|
||||
try:
|
||||
bridge_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".context-", dir=str(bridge_dir))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(record, handle, separators=(",", ":"))
|
||||
os.replace(tmp_path, str(bridge_dir / _CONTEXT_FILE))
|
||||
except OSError:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
_write_record_atomic(bridge_dir, record)
|
||||
except OSError as exc:
|
||||
print(f"omnigent claude status: write failed: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def _write_record_atomic(bridge_dir: Path, record: dict[str, object]) -> None:
|
||||
"""
|
||||
Atomically write one normalized record to ``context.json``.
|
||||
|
||||
:param bridge_dir: Bridge directory shared with the forwarder.
|
||||
:param record: Normalized record from :func:`normalize_status_payload`.
|
||||
:raises OSError: When the temp-file write or replace fails.
|
||||
"""
|
||||
bridge_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".context-", dir=str(bridge_dir))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(record, handle, separators=(",", ":"))
|
||||
os.replace(tmp_path, str(bridge_dir / _CONTEXT_FILE))
|
||||
except OSError:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
|
||||
def _chain(command: str, stdin_payload: str) -> None:
|
||||
"""
|
||||
Exec the user's pre-existing statusLine command, piping our stdin.
|
||||
|
||||
+335
-50
@@ -70,7 +70,7 @@ from omnigent.inner import _proc, ui
|
||||
from omnigent.integration_daemon import IntegrationDaemon
|
||||
from omnigent.json_types import JsonObject as _JsonObject
|
||||
from omnigent.onboarding.sandboxes import available_providers as _sandbox_providers
|
||||
from omnigent.process_logging import LOG_LEVEL_ENV_VAR, LOG_TO_STDERR_ENV_VAR
|
||||
from omnigent.process_logging import LOG_LEVEL_ENV_VAR, LOG_TO_STDERR_ENV_VAR, data_dir, env_truthy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import socket
|
||||
@@ -1781,6 +1781,98 @@ def _set_log_to_stderr(
|
||||
return value
|
||||
|
||||
|
||||
def _finish_cli_profile(profiler: Any, output_path: Path) -> None: # type: ignore[explicit-any]
|
||||
"""Stop a requested cProfile run and render a focused bottleneck summary."""
|
||||
import pstats
|
||||
|
||||
profiler.disable()
|
||||
profiler.dump_stats(output_path)
|
||||
stats = pstats.Stats(profiler)
|
||||
stats_state = vars(stats)
|
||||
raw_stats = cast(
|
||||
dict[tuple[str, int, str], tuple[int, int, float, float, object]],
|
||||
stats_state["stats"],
|
||||
)
|
||||
total_time = float(stats_state["total_tt"])
|
||||
total_calls = int(stats_state["total_calls"])
|
||||
primitive_calls = int(stats_state["prim_calls"])
|
||||
package_root = Path(__file__).resolve().parent
|
||||
source_root = package_root.parent
|
||||
|
||||
# (filename, line, function, primitive calls, total calls, self, cumulative)
|
||||
rows = [(*key, cc, nc, tt, ct) for key, (cc, nc, tt, ct, _) in raw_stats.items()]
|
||||
|
||||
def _location(filename: str, line: int, function: str) -> str:
|
||||
path = Path(filename).resolve()
|
||||
try:
|
||||
display = str(path.relative_to(source_root))
|
||||
except ValueError:
|
||||
display = path.name
|
||||
return f"{display}:{line}({function})"
|
||||
|
||||
def _duration(seconds: float) -> str:
|
||||
if seconds < 0.01:
|
||||
return f"{seconds * 1_000:.2f} ms"
|
||||
if seconds < 1:
|
||||
return f"{seconds * 1_000:.1f} ms"
|
||||
return f"{seconds:.3f} s"
|
||||
|
||||
def _print_rows(
|
||||
title: str,
|
||||
selected: list[tuple[str, int, str, int, int, float, float]],
|
||||
) -> None:
|
||||
click.echo(f"\n{title}", err=True)
|
||||
click.echo(f" {'self':>9} {'cumulative':>10} {'calls':>9} function", err=True)
|
||||
for filename, line, function, primitive, calls, self_time, cumulative in selected[:10]:
|
||||
call_count = str(calls) if calls == primitive else f"{calls}/{primitive}"
|
||||
click.echo(
|
||||
f" {_duration(self_time):>9} {_duration(cumulative):>10} "
|
||||
f"{call_count:>9} {_location(filename, line, function)}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
omnigent_rows = [
|
||||
row for row in rows if Path(row[0]).resolve().is_relative_to(package_root) and row[6] > 0
|
||||
]
|
||||
omnigent_rows.sort(key=lambda row: row[6], reverse=True)
|
||||
self_time_rows = [row for row in omnigent_rows if row[5] > 0]
|
||||
self_time_rows.sort(key=lambda row: row[5], reverse=True)
|
||||
|
||||
click.echo(
|
||||
f"\nCLI profile: {_duration(total_time)}, "
|
||||
f"{total_calls:,} calls ({primitive_calls:,} primitive)",
|
||||
err=True,
|
||||
)
|
||||
_print_rows("Top Omnigent call paths", omnigent_rows)
|
||||
_print_rows("Top Omnigent functions by self time", self_time_rows)
|
||||
click.echo(f"\nFull profile data: {output_path}", err=True)
|
||||
|
||||
|
||||
def _start_cli_profile(
|
||||
ctx: click.Context,
|
||||
_param: click.Parameter,
|
||||
value: bool,
|
||||
) -> bool:
|
||||
"""Start cProfile early and finish it after the selected command exits."""
|
||||
if not value:
|
||||
return value
|
||||
|
||||
import cProfile
|
||||
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
microseconds = time.time_ns() // 1_000 % 1_000_000
|
||||
profile_dir = data_dir() / "profiles"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = profile_dir / (f"omnigent-cli-{timestamp}-{microseconds:06d}-{os.getpid()}.prof")
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
ctx.call_on_close(lambda: _finish_cli_profile(profiler, output_path))
|
||||
# Click closes callbacks last-in-first-out, so measurement stops before
|
||||
# rendering the summary above.
|
||||
ctx.call_on_close(profiler.disable)
|
||||
return value
|
||||
|
||||
|
||||
def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, bool]:
|
||||
"""Remove global logging flags before run-shorthand rewriting."""
|
||||
debug_logging = False
|
||||
@@ -1801,6 +1893,17 @@ def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, boo
|
||||
|
||||
|
||||
@click.group(cls=_OmnigentCLI)
|
||||
@click.option(
|
||||
"--profiling",
|
||||
is_flag=True,
|
||||
is_eager=True,
|
||||
expose_value=False,
|
||||
callback=_start_cli_profile,
|
||||
help=(
|
||||
"Profile CLI execution, print a summary, and write a timestamped .prof file. "
|
||||
"Place before COMMAND."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
"debug_logging",
|
||||
@@ -1930,6 +2033,44 @@ def _warn_deprecated_harness_path_env_vars() -> None:
|
||||
)
|
||||
|
||||
|
||||
REQUIRE_WRAPPER_ENV = "OMNIGENT_REQUIRE_WRAPPER"
|
||||
WRAPPER_COMMAND_ENV = "OMNIGENT_WRAPPER_COMMAND"
|
||||
WRAPPER_BYPASS_ENV = "OMNIGENT_WRAPPER_BYPASS"
|
||||
|
||||
|
||||
def _wrapper_guard_error(env: Mapping[str, str], prog: str) -> str | None:
|
||||
"""Return the block message when a naked ``omni`` call is refused, else ``None``.
|
||||
|
||||
A deployment that wraps the CLI (e.g. ``isaac omni``) sets
|
||||
``OMNIGENT_REQUIRE_WRAPPER`` so direct calls are refused; the wrapper sets
|
||||
``OMNIGENT_WRAPPER_BYPASS`` around its own invocation to pass through, and
|
||||
``OMNIGENT_WRAPPER_COMMAND`` names the command to suggest instead.
|
||||
"""
|
||||
if not env_truthy(env.get(REQUIRE_WRAPPER_ENV)):
|
||||
return None
|
||||
if env_truthy(env.get(WRAPPER_BYPASS_ENV)):
|
||||
return None
|
||||
redirect = (env.get(WRAPPER_COMMAND_ENV) or "").strip()
|
||||
if redirect:
|
||||
detail = f"Use `{redirect}` instead, or set {WRAPPER_BYPASS_ENV}=1 to run it directly."
|
||||
else:
|
||||
detail = f"Set {WRAPPER_BYPASS_ENV}=1 to run it directly."
|
||||
return f"Error: running `{prog}` directly is disabled in this environment.\n{detail}"
|
||||
|
||||
|
||||
def _enforce_wrapper_guard() -> None:
|
||||
"""Exit early when a naked ``omni``/``omnigent`` call is blocked by an operator."""
|
||||
# argv[0] is the console-script name (``omni``/``omnigent``); ``python -m
|
||||
# omnigent`` reports ``__main__.py``, so fall back to the canonical name.
|
||||
prog = os.path.basename(sys.argv[0])
|
||||
if not prog or prog == "__main__.py":
|
||||
prog = "omnigent"
|
||||
message = _wrapper_guard_error(os.environ, prog)
|
||||
if message is not None:
|
||||
click.echo(message, err=True)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Console-script entry point for ``omnigent``.
|
||||
@@ -1961,6 +2102,11 @@ def main() -> None:
|
||||
|
||||
install_crash_handler(app_name="omnigent", repo="omnigent-ai/omnigent")
|
||||
|
||||
# Operators can force all use through a wrapper (e.g. `isaac omni`) by
|
||||
# setting OMNIGENT_REQUIRE_WRAPPER; the wrapper sets OMNIGENT_WRAPPER_BYPASS
|
||||
# to pass through. Refuse naked calls before any work happens.
|
||||
_enforce_wrapper_guard()
|
||||
|
||||
cwd = os.getcwd()
|
||||
if cwd not in sys.path:
|
||||
sys.path.insert(0, cwd)
|
||||
@@ -1988,7 +2134,17 @@ def main() -> None:
|
||||
# intentionally tiny (currently only help/version); runner flags live on
|
||||
# ``run``. Treat a leading non-top-level flag as bare-run shorthand so
|
||||
# users can type the natural no-AGENT launcher form.
|
||||
if argv and argv[0].startswith("-") and argv[0] not in {"--help", "-h", "--version"}:
|
||||
if (
|
||||
argv
|
||||
and argv[0].startswith("-")
|
||||
and argv[0]
|
||||
not in {
|
||||
"--help",
|
||||
"-h",
|
||||
"--version",
|
||||
"--profiling",
|
||||
}
|
||||
):
|
||||
argv = ["run", *argv]
|
||||
|
||||
# Shorthand: ``omnigent myagent.yaml [opts]`` → ``run myagent.yaml [opts]``.
|
||||
@@ -2158,6 +2314,10 @@ def _is_removed_ad_hoc_invocation(argv: list[str]) -> bool:
|
||||
# help listing subcommands, not the legacy argparse help.
|
||||
if argv[0] in {"--help", "-h", "--version"}:
|
||||
return False
|
||||
# A root profiling flag may precede an eager help/version flag or stand
|
||||
# alone. These are valid Click invocations, not removed ad-hoc chat.
|
||||
if all(token in {"--profiling", "--help", "-h", "--version"} for token in argv):
|
||||
return False
|
||||
# Skip leading flags to find the first positional. If all
|
||||
# tokens are flags (e.g. ``omnigent --system-prompt "..."``),
|
||||
# treat it as removed ad-hoc chat rather than handing it to click
|
||||
@@ -2185,7 +2345,7 @@ def _runner_loopback_host(host: str) -> str:
|
||||
return "127.0.0.1" if host in {"0.0.0.0", "::", ""} else host
|
||||
|
||||
|
||||
_HOST_PID_PATH = Path.home() / ".omnigent" / "host.pid"
|
||||
_HOST_PID_PATH = data_dir() / "host.pid"
|
||||
|
||||
|
||||
# host.pid records the daemon PID + the "target" it serves: a normalized
|
||||
@@ -2888,7 +3048,7 @@ def _foreground_daemon_record(
|
||||
started_at=int(time.time()),
|
||||
host_id=host_id,
|
||||
resolved_server_url=server_url.rstrip("/") if mode == "local" else None,
|
||||
config_sig=server_config_signature(),
|
||||
config_sig=server_config_signature(include_features=mode == "local"),
|
||||
)
|
||||
|
||||
|
||||
@@ -2928,11 +3088,13 @@ def _claim_foreground_daemon_record(
|
||||
"""
|
||||
conflict = _live_daemon_conflict(record)
|
||||
if conflict is not None:
|
||||
# server_url is None in local mode; "" makes the hint say --server "".
|
||||
stop_command = _host_stop_command(conflict.server_url or "")
|
||||
raise click.ClickException(
|
||||
"A host daemon is already running for this server "
|
||||
f"(pid={conflict.pid}, target={conflict.target}). "
|
||||
"Run `omnigent host status` to inspect it or "
|
||||
"`omnigent host stop --server ...` to stop it first."
|
||||
f"Run `omnigent host status` to inspect it or `{stop_command}` "
|
||||
"to stop it first."
|
||||
)
|
||||
previous = _find_daemon_record(record.target)
|
||||
if previous is not None and not _pid_alive(previous.pid):
|
||||
@@ -3008,14 +3170,15 @@ def _ensure_host_daemon(server_url: str | None) -> bool:
|
||||
mode_args = ["--local"] if not server_url else ["--server", server_url]
|
||||
args = [sys.executable, "-m", "omnigent.host._daemon_entry", *mode_args]
|
||||
spawned = _spawn_host_daemon_process(
|
||||
args=args, env=_build_host_daemon_env(server_url=server_url)
|
||||
args=args,
|
||||
env=_build_host_daemon_env(server_url=server_url),
|
||||
)
|
||||
if spawned is None:
|
||||
return False
|
||||
_persist_spawned_daemon(
|
||||
target=target,
|
||||
spawned=spawned,
|
||||
config_sig=server_config_signature(),
|
||||
config_sig=server_config_signature(include_features=not server_url),
|
||||
)
|
||||
return decision.config_changed
|
||||
|
||||
@@ -3115,9 +3278,10 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
today. A non-200 answer that carries the Databricks edge signature
|
||||
(302 to the workspace OAuth page, or a DatabricksRealm 401) means
|
||||
the run would otherwise die much later with an opaque "non-JSON
|
||||
response (status=302)" traceback from the session-create call. On a
|
||||
TTY we run the same flow ``omnigent login`` would and continue;
|
||||
headless invocations get the exact command to run instead.
|
||||
response (status=302)" traceback from the session-create call. First,
|
||||
it asks the SDK for a fresh workspace token; only then does a TTY run
|
||||
the same flow ``omnigent login`` would, while headless invocations get
|
||||
the exact command to run instead.
|
||||
|
||||
Non-Databricks postures are deliberately left alone: local accounts
|
||||
servers auto-authenticate downstream (magic-link redeem), and
|
||||
@@ -3137,6 +3301,7 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
import httpx as _httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
from omnigent.cli_auth import load_databricks_org_id, store_databricks_auth
|
||||
|
||||
try:
|
||||
probe = _httpx.get(
|
||||
@@ -3153,6 +3318,13 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
workspace_host = _databricks_workspace_login_target(server, probe)
|
||||
if workspace_host is None:
|
||||
return
|
||||
org_id = load_databricks_org_id(server)
|
||||
token = _databricks_workspace_token(workspace_host)
|
||||
if token is not None:
|
||||
refreshed_probe = _verify_databricks_server_token(server, token, org_id)
|
||||
if refreshed_probe.status_code == 200:
|
||||
store_databricks_auth(server, workspace_host, org_id=org_id)
|
||||
return
|
||||
login_cmd = f"omnigent login {server}"
|
||||
if non_interactive or not sys.stdin.isatty():
|
||||
raise click.ClickException(
|
||||
@@ -3160,11 +3332,7 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
f"HTTP {probe.status_code}). Run `{login_cmd}` and retry."
|
||||
)
|
||||
click.echo(f"Not signed in to {server} — running `{login_cmd}` first.")
|
||||
# Recover the ``?o=`` selector from a prior login record so a re-login
|
||||
# still targets the right workspace.
|
||||
from omnigent.cli_auth import load_databricks_org_id
|
||||
|
||||
_databricks_login(server, workspace_host, org_id=load_databricks_org_id(server))
|
||||
_databricks_login(server, workspace_host, org_id=org_id)
|
||||
|
||||
|
||||
def _ensure_backend(server: str | None) -> str:
|
||||
@@ -3789,6 +3957,10 @@ def server(
|
||||
|
||||
cfg = _load_config(config_path)
|
||||
|
||||
# Let the server-config reader (branding) see the same ``-c`` file.
|
||||
if config_path:
|
||||
os.environ["OMNIGENT_CONFIG"] = str(Path(config_path).resolve())
|
||||
|
||||
# CLI args take precedence over config file, which takes precedence
|
||||
# over defaults.
|
||||
db_uri = database_uri or cfg.get("database_uri", _default_db_uri())
|
||||
@@ -5687,6 +5859,7 @@ def import_session_command(
|
||||
import httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
from omnigent.conversation_browser import conversation_url
|
||||
from omnigent.session_import import (
|
||||
ImportSource,
|
||||
SessionImportNotFoundError,
|
||||
@@ -5792,13 +5965,17 @@ def import_session_command(
|
||||
)
|
||||
continue
|
||||
imported_count += 1
|
||||
# Surface the browser URL, not the bare id, so the user can open the
|
||||
# imported session straight into the web (where it offers the resume
|
||||
# picker). Maps a Databricks API base to its workspace SPA link.
|
||||
session_link = conversation_url(base_url, session_id)
|
||||
if is_batch:
|
||||
click.echo(
|
||||
f"Imported {item_count} item(s) from {current_source_session_id} "
|
||||
f"into {session_id}."
|
||||
f"into {session_link}"
|
||||
)
|
||||
else:
|
||||
click.echo(f"Imported {item_count} item(s) into {session_id}.")
|
||||
click.echo(f"Imported {item_count} item(s) into {session_link}")
|
||||
|
||||
if is_batch:
|
||||
click.echo(f"\nImported: {imported_count}")
|
||||
@@ -7824,6 +8001,9 @@ def _prompt_stop_local_server() -> None:
|
||||
# server URL, missing credentials) leaves nothing on the terminal, so we wait
|
||||
# this long and surface its log instead of falsely reporting success.
|
||||
_BACKGROUND_HOST_GRACE_S = 2.0
|
||||
# A detached process isn't ready merely because its PID survived. Wait until
|
||||
# the server confirms the host row and live tunnel are online.
|
||||
_BACKGROUND_HOST_REGISTRATION_GRACE_S = 30.0
|
||||
|
||||
|
||||
def _confirm_background_host_alive(record: _HostDaemonRecord) -> None:
|
||||
@@ -7836,18 +8016,50 @@ def _confirm_background_host_alive(record: _HostDaemonRecord) -> None:
|
||||
deadline = time.time() + _BACKGROUND_HOST_GRACE_S
|
||||
while True:
|
||||
if not _pid_alive(record.pid):
|
||||
from omnigent._runner_startup import format_runner_log_tail
|
||||
|
||||
log_path = Path(record.log_path) if record.log_path else None
|
||||
raise click.ClickException(
|
||||
"The host daemon exited immediately after starting."
|
||||
f"{format_runner_log_tail(log_path)}"
|
||||
f"{_background_host_log_detail(record.log_path)}"
|
||||
)
|
||||
if time.time() >= deadline:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _background_host_log_detail(log_path: str | None) -> str:
|
||||
"""Return the host log path and a short failure tail."""
|
||||
if log_path is None:
|
||||
return ""
|
||||
path = Path(log_path)
|
||||
detail = f"\nHost log: {path}"
|
||||
try:
|
||||
tail = path.read_bytes()[-4096:].decode("utf-8", errors="replace").strip()
|
||||
except OSError:
|
||||
return detail
|
||||
if tail:
|
||||
detail += "\n--- host log tail ---\n" + "\n".join(tail.splitlines()[-12:])
|
||||
return detail
|
||||
|
||||
|
||||
def _confirm_background_host_registered(record: _HostDaemonRecord) -> None:
|
||||
"""Wait until the detached daemon completes server registration."""
|
||||
deadline = time.monotonic() + _BACKGROUND_HOST_REGISTRATION_GRACE_S
|
||||
while True:
|
||||
if not _pid_alive(record.pid):
|
||||
raise click.ClickException(
|
||||
"The host daemon exited before registering with the server."
|
||||
f"{_background_host_log_detail(record.log_path)}"
|
||||
)
|
||||
if _daemon_host_online(record, timeout_s=1.0):
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
raise click.ClickException(
|
||||
"The host daemon started but did not register with the server "
|
||||
f"within {_BACKGROUND_HOST_REGISTRATION_GRACE_S:.0f}s."
|
||||
f"{_background_host_log_detail(record.log_path)}"
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def _run_background_host(
|
||||
server: str | None,
|
||||
*,
|
||||
@@ -7875,7 +8087,7 @@ def _run_background_host(
|
||||
:param non_interactive: When ``True``, never launch the browser login —
|
||||
fail with the ``omnigent login`` hint instead.
|
||||
:raises click.ClickException: If the daemon cannot be spawned, exits
|
||||
immediately after starting, or (local mode) never serves its local
|
||||
immediately, fails to register, or (local mode) never serves its local
|
||||
Omnigent server.
|
||||
"""
|
||||
if server:
|
||||
@@ -7885,30 +8097,40 @@ def _run_background_host(
|
||||
_ensure_host_daemon(server or None)
|
||||
record = _find_daemon_record(target)
|
||||
if record is None:
|
||||
# No record for this target: either the live local-mode daemon already
|
||||
# serves the requested URL, or the spawn itself failed.
|
||||
# A local daemon may already own the requested URL under its local
|
||||
# registry key. It is reusable only after its host is online too.
|
||||
if _local_daemon_serves_target(target, server or None):
|
||||
click.echo(f"The local host daemon already serves {target}.")
|
||||
return
|
||||
local_record = _find_daemon_record(_LOCAL_DAEMON_MARKER)
|
||||
if local_record is not None:
|
||||
_confirm_background_host_registered(local_record)
|
||||
click.echo(f"The local host daemon already serves {target}.")
|
||||
return
|
||||
raise click.ClickException(
|
||||
"Could not spawn the background host daemon. See ~/.omnigent/logs/host/ for details."
|
||||
)
|
||||
if previous is not None and previous.pid == record.pid:
|
||||
headline = _cli_style("Host daemon already running", fg="yellow", bold=True)
|
||||
else:
|
||||
_confirm_background_host_alive(record)
|
||||
headline = _cli_style("Started the host daemon in the background", fg="green", bold=True)
|
||||
reused = previous is not None and previous.pid == record.pid
|
||||
try:
|
||||
if not reused:
|
||||
_confirm_background_host_alive(record)
|
||||
if record.mode == "local":
|
||||
# The status probe needs the daemon-owned server's loopback URL.
|
||||
server_url = _discover_local_server_url()
|
||||
_update_daemon_resolved_server_url(target, server_url)
|
||||
record = _find_daemon_record(target) or record
|
||||
else:
|
||||
server_url = target
|
||||
_confirm_background_host_registered(record)
|
||||
except click.ClickException:
|
||||
if not reused:
|
||||
with contextlib.suppress(click.ClickException):
|
||||
_terminate_daemon(record, force=True)
|
||||
raise
|
||||
headline = _cli_style(
|
||||
"Host daemon already running" if reused else "Started the host daemon in the background",
|
||||
fg="yellow" if reused else "green",
|
||||
bold=True,
|
||||
)
|
||||
click.echo(f"{headline} (pid {record.pid}).")
|
||||
if record.mode == "local":
|
||||
# A local-mode daemon owns the local Omnigent server, so this command is
|
||||
# the whole "start everything" step — wait for that server and report
|
||||
# its URL, otherwise the Web UI is unreachable without a follow-up
|
||||
# `omnigent server status`. Resolved after the headline above so a cold
|
||||
# start isn't a silent terminal.
|
||||
server_url = _discover_local_server_url()
|
||||
_update_daemon_resolved_server_url(target, server_url)
|
||||
else:
|
||||
server_url = target
|
||||
_echo_host_field("server", _cli_style(server_url, fg="cyan"))
|
||||
if record.log_path is not None:
|
||||
_echo_host_field("log", _display_path(Path(record.log_path)))
|
||||
@@ -9095,6 +9317,50 @@ def _stop_daemon_sessions(
|
||||
return stopped
|
||||
|
||||
|
||||
def _signal_daemon_pid(record: _HostDaemonRecord, sig: int) -> bool:
|
||||
"""
|
||||
Signal a daemon's recorded PID, tolerating a stale foreign entry.
|
||||
|
||||
A daemon registry record can outlive the process it names. The recorded
|
||||
PID may since have been reused by an unrelated process — often owned by
|
||||
another user — or the real daemon may have been started under a different
|
||||
account (e.g. ``sudo``). In both cases the PID is no longer this user's
|
||||
daemon and must not be killed.
|
||||
|
||||
``os.kill`` raises ``PermissionError`` (EPERM) when the caller lacks
|
||||
permission to signal the target — which, since we only ever signal our
|
||||
own daemons, means the record is stale and points at someone else's
|
||||
process. ``_pid_alive`` reports such a PID as alive (``psutil`` maps the
|
||||
same permission failure to ``AccessDenied``), so without this guard
|
||||
``_terminate_daemon`` would fall through to ``os.kill`` and crash on the
|
||||
unsuppressed EPERM — ``--force`` included, since it dies before the
|
||||
SIGKILL path. Log a warning and treat the record as stale instead.
|
||||
|
||||
:param record: Daemon record whose PID should be signalled.
|
||||
:param sig: Signal number to send, e.g. ``signal.SIGTERM``.
|
||||
:returns: ``True`` if the record is stale and the caller should drop it
|
||||
and stop (either the PID is not ours, or it already exited); ``False``
|
||||
if the signal was delivered and termination should proceed as usual.
|
||||
"""
|
||||
try:
|
||||
os.kill(record.pid, sig)
|
||||
except ProcessLookupError:
|
||||
# The process exited between the liveness check and the signal —
|
||||
# nothing left to kill, so the record is stale.
|
||||
return True
|
||||
except PermissionError:
|
||||
# Not our daemon: the record points at another user's process (PID
|
||||
# reuse, or a daemon started under a different account). Drop the
|
||||
# stale record and warn rather than crashing the CLI on the EPERM.
|
||||
click.echo(
|
||||
f"Skipping stale daemon record for {record.target!r}: pid "
|
||||
f"{record.pid} is owned by another user and is not this daemon.",
|
||||
err=True,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
|
||||
"""
|
||||
Terminate one local daemon process.
|
||||
@@ -9106,8 +9372,9 @@ def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
|
||||
if not _pid_alive(record.pid):
|
||||
_delete_daemon_record(record)
|
||||
return
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(record.pid, signal.SIGTERM)
|
||||
if _signal_daemon_pid(record, signal.SIGTERM):
|
||||
_delete_daemon_record(record)
|
||||
return
|
||||
deadline = time.monotonic() + _HOST_DAEMON_STOP_GRACE_S
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_alive(record.pid):
|
||||
@@ -9115,8 +9382,9 @@ def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
if force:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(record.pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
||||
if _signal_daemon_pid(record, getattr(signal, "SIGKILL", signal.SIGTERM)):
|
||||
_delete_daemon_record(record)
|
||||
return
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_alive(record.pid):
|
||||
@@ -10799,6 +11067,12 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
|
||||
the workspace rejects it).
|
||||
:raises click.ClickException: When the Databricks CLI binary is
|
||||
missing or the login exits non-zero.
|
||||
|
||||
The login writes to a ``.databrickscfg`` profile named after the
|
||||
workspace's first DNS label (e.g. ``acme`` for
|
||||
``acme.cloud.databricks.com``), keeping distinct workspaces off the
|
||||
shared ``DEFAULT`` profile. The OAuth grant itself stays host-keyed,
|
||||
so :func:`_databricks_workspace_token` still resolves it by host.
|
||||
"""
|
||||
databricks_bin = shutil.which("databricks")
|
||||
if databricks_bin is None:
|
||||
@@ -10807,14 +11081,21 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
|
||||
"Install it first: https://docs.databricks.com/dev-tools/cli/install.html"
|
||||
)
|
||||
login_host = _host_with_org(workspace_host, org_id)
|
||||
click.echo(f"Opening browser to log in to {login_host} ...")
|
||||
# Pin the grant to a profile named for the workspace's first DNS label so
|
||||
# distinct workspaces don't clobber each other under the CLI's ``DEFAULT``.
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
split = urlsplit(workspace_host.rstrip("/"))
|
||||
host = split.hostname or split.netloc or split.path
|
||||
profile = host.split(".")[0]
|
||||
click.echo(f"Opening browser to log in to {login_host} (profile {profile}) ...")
|
||||
result = subprocess.run(
|
||||
[databricks_bin, "auth", "login", "--host", login_host],
|
||||
[databricks_bin, "auth", "login", "--host", login_host, "--profile", profile],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
f"`databricks auth login --host {login_host}` failed "
|
||||
f"`databricks auth login --host {login_host} --profile {profile}` failed "
|
||||
f"(exit {result.returncode}). If the workspace is unreachable from "
|
||||
"this machine (VPN / IP access lists), resolve that and retry."
|
||||
)
|
||||
@@ -10867,7 +11148,7 @@ def _databricks_workspace_token(workspace_host: str) -> str | None:
|
||||
try:
|
||||
auth, _host = _resolve_databricks_auth(host=workspace_host)
|
||||
return auth.current_token()
|
||||
except (DatabricksAuthError, ValueError):
|
||||
except (DatabricksAuthError, ImportError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -11037,6 +11318,9 @@ def login(server_url: str) -> None:
|
||||
token=token,
|
||||
user_id=user_id,
|
||||
expires_at=_time.time() + expires_in,
|
||||
# Login-issued refresh grant (newer servers) — lets the
|
||||
# host/CLI renew past session expiry unattended.
|
||||
refresh_token=result.get("refresh_token"),
|
||||
)
|
||||
click.echo(f"Logged in as {user_id}")
|
||||
_remember_default_server(server)
|
||||
@@ -11116,6 +11400,7 @@ def _accounts_login(server: str) -> None:
|
||||
token=token,
|
||||
user_id=user_id,
|
||||
expires_at=_time.time() + expires_in,
|
||||
refresh_token=body.get("refresh_token"),
|
||||
)
|
||||
click.echo(f"Logged in as {user_id}.")
|
||||
|
||||
|
||||
+266
-12
@@ -21,11 +21,13 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -35,13 +37,22 @@ if TYPE_CHECKING:
|
||||
_logger = logging.getLogger(__name__)
|
||||
_TOKEN_FILE_NAME = "auth_tokens.json"
|
||||
|
||||
# Treat a stored token with less than this much life left as needing
|
||||
# renewal. Shared by the "is it still usable" read path and the refresh
|
||||
# path's already-renewed check, so a caller that decides to refresh is
|
||||
# never handed back the same near-expiry token it wanted to replace.
|
||||
# Comfortably longer than a WebSocket handshake, far shorter than the
|
||||
# server's 1-hour access-token TTL.
|
||||
REFRESH_MIN_REMAINING_SECONDS = 90.0
|
||||
|
||||
|
||||
def _token_file_path() -> Path:
|
||||
"""Return the path to the auth token storage file.
|
||||
|
||||
Uses the shared ``~/.omnigent`` state directory.
|
||||
Uses the shared Omnigent state directory, honoring
|
||||
``OMNIGENT_DATA_DIR``.
|
||||
|
||||
:returns: Path to ``~/.omnigent/auth_tokens.json``.
|
||||
:returns: Path to ``<data-dir>/auth_tokens.json``.
|
||||
"""
|
||||
from omnigent_ui_sdk.terminal._config import state_dir
|
||||
|
||||
@@ -133,6 +144,10 @@ def _store_entry(server_url: str, entry: dict[str, str | float]) -> None:
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = {}
|
||||
|
||||
# Corrupt token files (non-dict JSON) read as empty — never crash.
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
data[_normalize_server_url(server_url)] = entry
|
||||
|
||||
_write_tokens_file(path, data)
|
||||
@@ -143,6 +158,7 @@ def store_token(
|
||||
token: str,
|
||||
user_id: str,
|
||||
expires_at: float,
|
||||
refresh_token: str | None = None,
|
||||
) -> None:
|
||||
"""Persist a session token for a server.
|
||||
|
||||
@@ -152,15 +168,19 @@ def store_token(
|
||||
:param user_id: The authenticated user's email, e.g.
|
||||
``"alice@example.com"``.
|
||||
:param expires_at: Unix timestamp when the token expires.
|
||||
:param refresh_token: Login-issued refresh grant token, when the
|
||||
server handed one out. Lets :func:`refresh_stored_token` renew
|
||||
the access token past expiry without a human re-running
|
||||
``omnigent login``.
|
||||
"""
|
||||
_store_entry(
|
||||
server_url,
|
||||
{
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
entry: dict[str, str | float] = {
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
if refresh_token is not None:
|
||||
entry["refresh_token"] = refresh_token
|
||||
_store_entry(server_url, entry)
|
||||
|
||||
|
||||
def store_databricks_auth(
|
||||
@@ -216,11 +236,15 @@ def _load_entry(server_url: str) -> dict[str, str | float] | None:
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
# A token file holding valid JSON of the wrong shape (``[]``, ``null``,
|
||||
# a bare string) is corrupt, not fatal — read as "nothing stored".
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
entry = data.get(_normalize_server_url(server_url))
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def load_token(server_url: str) -> str | None:
|
||||
def load_token(server_url: str, *, min_remaining_seconds: float = 0.0) -> str | None:
|
||||
"""Load a stored session token for a server.
|
||||
|
||||
Returns ``None`` if no token is stored, the token has expired,
|
||||
@@ -230,6 +254,11 @@ def load_token(server_url: str) -> str | None:
|
||||
|
||||
:param server_url: The server URL, e.g.
|
||||
``"http://localhost:6767"``.
|
||||
:param min_remaining_seconds: Require at least this much remaining
|
||||
lifetime. ``0`` (the default) accepts any not-yet-expired token —
|
||||
the historical behaviour. A caller that can renew passes
|
||||
:data:`REFRESH_MIN_REMAINING_SECONDS` so a token about to lapse
|
||||
mid-handshake is refreshed instead of used.
|
||||
:returns: The session JWT string, or ``None``.
|
||||
"""
|
||||
entry = _load_entry(server_url)
|
||||
@@ -238,13 +267,238 @@ def load_token(server_url: str) -> str | None:
|
||||
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
if isinstance(expires_at, (int, float)) and expires_at < time.time():
|
||||
_logger.debug("Stored token for %s has expired", _normalize_server_url(server_url))
|
||||
_warn_expired_once(server_url, expires_at, has_refresh="refresh_token" in entry)
|
||||
return None
|
||||
# Near-expiry but still valid: decline quietly (no expiry warning — it
|
||||
# has not expired) so the caller can choose to renew.
|
||||
if (
|
||||
min_remaining_seconds > 0
|
||||
and isinstance(expires_at, (int, float))
|
||||
and expires_at - time.time() < min_remaining_seconds
|
||||
):
|
||||
return None
|
||||
|
||||
token = entry.get("token")
|
||||
return token if isinstance(token, str) else None
|
||||
|
||||
|
||||
# Servers already warned about an expired stored token, so a poll/retry
|
||||
# loop doesn't repeat the warning every few seconds.
|
||||
_warned_expired_servers: set[str] = set()
|
||||
|
||||
|
||||
def _warn_expired_once(server_url: str, expires_at: float, *, has_refresh: bool) -> None:
|
||||
"""Warn (once per process per server) that a stored token expired.
|
||||
|
||||
Expiry used to be a DEBUG line, which left the host dialing
|
||||
unauthenticated into a misleading 403 with no breadcrumb — an
|
||||
operator's first actionable signal must name the cause and the
|
||||
remedy.
|
||||
"""
|
||||
normalized = _normalize_server_url(server_url)
|
||||
if normalized in _warned_expired_servers:
|
||||
return
|
||||
_warned_expired_servers.add(normalized)
|
||||
expired_on = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(expires_at))
|
||||
if has_refresh:
|
||||
_logger.warning(
|
||||
"Stored login session for %s expired on %s; refresh will be "
|
||||
"attempted on the next command if possible",
|
||||
normalized,
|
||||
expired_on,
|
||||
)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Stored login session for %s expired on %s and holds no refresh "
|
||||
"material. Run `omnigent login %s` to re-authenticate.",
|
||||
normalized,
|
||||
expired_on,
|
||||
normalized,
|
||||
)
|
||||
|
||||
|
||||
def stored_token_status(server_url: str) -> str:
|
||||
"""Classify the stored auth state for a server.
|
||||
|
||||
Lets callers distinguish "never logged in" from "logged in but the
|
||||
session lapsed" — the difference between proceeding unauthenticated
|
||||
(header-mode servers accept that) and surfacing an actionable
|
||||
re-login message.
|
||||
|
||||
:param server_url: The server URL.
|
||||
:returns: ``"ok"`` (valid token stored), ``"expired"`` (entry exists
|
||||
but the token lapsed), or ``"absent"`` (no token entry at all;
|
||||
includes Databricks pointer records, which hold no token).
|
||||
"""
|
||||
entry = _load_entry(server_url)
|
||||
if entry is None or not isinstance(entry.get("token"), str):
|
||||
return "absent"
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
if isinstance(expires_at, (int, float)) and expires_at < time.time():
|
||||
return "expired"
|
||||
return "ok"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _token_file_lock() -> Iterator[None]:
|
||||
"""Exclusive advisory lock over token-file read-modify-write cycles.
|
||||
|
||||
Serializes concurrent refreshes on one machine (host + CLI sharing
|
||||
``auth_tokens.json``) so only one performs the network exchange and
|
||||
the other picks up its result. Best-effort on platforms without
|
||||
``fcntl`` (Windows): the refresh still works, only the local
|
||||
serialization is lost.
|
||||
|
||||
:raises OSError: If the lock file cannot be created or opened — the
|
||||
caller degrades to "cannot refresh" (a state directory we cannot
|
||||
write is one we could not persist the result to either).
|
||||
"""
|
||||
lock_path = _token_file_path().with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
with open(lock_path, "w") as lock_file:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def refresh_stored_token(server_url: str, *, timeout: float = 10.0) -> str | None:
|
||||
"""Renew the stored access token from its login-issued refresh grant.
|
||||
|
||||
POSTs ``grant_type=refresh_token`` to the server's ``/oauth/token``,
|
||||
persists the result, and returns the fresh access token. Safe to call
|
||||
opportunistically: returns ``None`` when there is nothing to refresh
|
||||
(no entry / no refresh material) or when the server refuses (grant
|
||||
revoked, past its absolute lifetime, or an older server without the
|
||||
endpoint).
|
||||
|
||||
Runs under the token-file lock and re-checks state after acquiring
|
||||
it, so of two concurrent callers only one performs the network
|
||||
refresh and the other returns the already-renewed token.
|
||||
|
||||
:param server_url: The server URL, e.g. ``"http://localhost:6767"``.
|
||||
:param timeout: HTTP timeout in seconds.
|
||||
:returns: A valid access token, or ``None``.
|
||||
"""
|
||||
normalized = _normalize_server_url(server_url)
|
||||
# Cheap pre-check BEFORE touching the lock file: with no refresh
|
||||
# material there is nothing to do, and creating a lock file would
|
||||
# raise on a read-only state directory — masking the caller's other
|
||||
# credential sources (e.g. the Databricks SDK fallback).
|
||||
pre = _load_entry(server_url)
|
||||
if pre is None or not isinstance(pre.get("refresh_token"), str) or not pre["refresh_token"]:
|
||||
return None
|
||||
try:
|
||||
with _token_file_lock():
|
||||
return _refresh_locked(server_url, normalized, timeout)
|
||||
except OSError as exc:
|
||||
# Cannot lock/persist (read-only or full state dir) — a refresh we
|
||||
# could not store is worse than none, so decline and let the caller
|
||||
# fall through to its other credential sources.
|
||||
_logger.debug("Token refresh for %s skipped, state dir unusable: %s", normalized, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _refresh_locked(server_url: str, normalized: str, timeout: float) -> str | None:
|
||||
"""Perform the refresh exchange; caller holds the token-file lock."""
|
||||
entry = _load_entry(server_url)
|
||||
if entry is None:
|
||||
return None
|
||||
# Another process may have refreshed while we waited on the lock. A
|
||||
# freshly minted token is far from expiry, so this cleanly separates
|
||||
# "someone already renewed" from "this is the same near-expiry token".
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
token = entry.get("token")
|
||||
if (
|
||||
isinstance(token, str)
|
||||
and isinstance(expires_at, (int, float))
|
||||
and expires_at - time.time() > REFRESH_MIN_REMAINING_SECONDS
|
||||
):
|
||||
return token
|
||||
refresh_token = entry.get("refresh_token")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
return None
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{normalized}/oauth/token",
|
||||
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
_logger.warning("Token refresh against %s failed: %s", normalized, exc)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning(
|
||||
"Token refresh against %s refused (HTTP %d) — run `omnigent login %s` "
|
||||
"to re-authenticate.",
|
||||
normalized,
|
||||
resp.status_code,
|
||||
normalized,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
_logger.warning("Token refresh against %s returned a malformed response", normalized)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
_logger.warning("Token refresh against %s returned a malformed response", normalized)
|
||||
return None
|
||||
access_token = payload.get("access_token")
|
||||
new_refresh = payload.get("refresh_token")
|
||||
# Only overwrite the stored pair with genuinely usable material —
|
||||
# a null/non-string field must never clobber a working credential.
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
_logger.warning("Token refresh against %s returned no access token", normalized)
|
||||
return None
|
||||
if not isinstance(new_refresh, str) or not new_refresh:
|
||||
# A server that renews without returning refresh material keeps the
|
||||
# one we already hold (login grants deliberately do not rotate).
|
||||
new_refresh = refresh_token
|
||||
expires_in = _coerce_expires_in(payload.get("expires_in"))
|
||||
|
||||
user_id = entry.get("user_id")
|
||||
store_token(
|
||||
server_url,
|
||||
token=access_token,
|
||||
user_id=user_id if isinstance(user_id, str) else "",
|
||||
expires_at=time.time() + expires_in,
|
||||
refresh_token=new_refresh,
|
||||
)
|
||||
# A fresh token means any earlier expiry warning is stale; allow
|
||||
# a new one if this credential ever lapses again.
|
||||
_warned_expired_servers.discard(normalized)
|
||||
_logger.info("Refreshed login session for %s", normalized)
|
||||
return access_token
|
||||
|
||||
|
||||
def _coerce_expires_in(raw: object) -> float:
|
||||
"""Return a sane access-token lifetime in seconds from *raw*.
|
||||
|
||||
Falls back to one hour for anything missing, non-numeric, or
|
||||
non-finite — ``float("NaN")``/``float("Infinity")`` parse happily but
|
||||
would yield an expiry that never compares as expired, pinning a dead
|
||||
token forever.
|
||||
"""
|
||||
default = 3600.0
|
||||
try:
|
||||
value = float(raw) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def load_databricks_workspace_host(server_url: str) -> str | None:
|
||||
"""Load the workspace host from a Databricks Apps pointer record.
|
||||
|
||||
|
||||
@@ -3794,8 +3794,19 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
# discoverable than a user's own `acp:` entry.
|
||||
from omnigent._platform import resolve_cli_binary
|
||||
from omnigent.acp_cli_harnesses import ACP_CLI_HARNESSES
|
||||
from omnigent.onboarding.acp_auth import acp_agents, shadowed_builtin_acp_rows
|
||||
|
||||
# Skip a row a configured `acp:` agent already claims, so the list shows
|
||||
# one "Devin" (the user's, with its command) rather than two identically
|
||||
# labeled rows. A config error is reported by the custom-ACP block below.
|
||||
try:
|
||||
_shadowed_acp_rows: frozenset[str] = shadowed_builtin_acp_rows(acp_agents(config))
|
||||
except ValueError:
|
||||
_shadowed_acp_rows = frozenset()
|
||||
|
||||
for _acp_cli_name, _acp_cli_row in sorted(ACP_CLI_HARNESSES.items()):
|
||||
if _acp_cli_name in _shadowed_acp_rows:
|
||||
continue
|
||||
_acp_cli_key = _ACP_CLI_PREFIX + _acp_cli_name
|
||||
if resolve_cli_binary(_acp_cli_row.binary) is None:
|
||||
rows.append(
|
||||
|
||||
@@ -853,11 +853,14 @@ async def _prepare_codex_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Codex session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Codex session...")
|
||||
session_id = await _create_codex_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=None,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_codex_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=None,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Codex session...")
|
||||
@@ -910,7 +913,8 @@ async def _prepare_codex_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
@@ -1196,6 +1200,7 @@ async def _prepare_codex_terminal(
|
||||
socket_path=codex_ws_url,
|
||||
thread_id=thread_id,
|
||||
codex_home=str(codex_home),
|
||||
cwd=str(Path.cwd()),
|
||||
),
|
||||
)
|
||||
if runner_id is not None:
|
||||
@@ -1417,6 +1422,7 @@ async def _initialize_fresh_terminal_thread(
|
||||
socket_path=app_server_url,
|
||||
thread_id=thread_id,
|
||||
codex_home=str(codex_home_for_bridge_dir(prepared.bridge_dir)),
|
||||
cwd=str(Path.cwd()),
|
||||
),
|
||||
)
|
||||
return thread_id
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
|
||||
from omnigent.onboarding.provider_config import ProviderEntry
|
||||
from omnigent.spec.types import AgentSpec
|
||||
|
||||
from omnigent.codex_model_vocabulary import codex_spawn_model
|
||||
from omnigent.codex_native_bridge import write_policy_hook_config
|
||||
from omnigent.codex_native_process_registry import (
|
||||
CodexNativeProcessOwnerLock,
|
||||
@@ -57,6 +59,7 @@ from omnigent.inner.codex_executor import (
|
||||
codex_router_session_id,
|
||||
codex_routing_hook_skip_reason,
|
||||
materialize_codex_provider_config,
|
||||
read_codex_model_catalog,
|
||||
write_codex_hooks_file,
|
||||
)
|
||||
from omnigent.inner.databricks_executor import _databricks_gateway_host
|
||||
@@ -110,6 +113,7 @@ _MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
|
||||
# (including a version we could not parse) the flag is omitted and the
|
||||
# interactive trust prompt may appear instead.
|
||||
_MIN_BYPASS_HOOK_TRUST_CODEX_VERSION = (0, 131, 0)
|
||||
_MODEL_MIGRATION_CATALOG_TIMEOUT_SECONDS = 3.0
|
||||
|
||||
|
||||
def _string_object_dict(value: object) -> _JsonObject | None:
|
||||
@@ -386,6 +390,43 @@ def _sync_codex_developer_instructions(
|
||||
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
def _codex_model_upgrade_target(catalog: object, model: str) -> str | None:
|
||||
"""Return Codex's replacement for *model*, when the catalog declares one."""
|
||||
if not isinstance(catalog, dict):
|
||||
return None
|
||||
models = catalog.get("models")
|
||||
if not isinstance(models, list):
|
||||
return None
|
||||
for entry in models:
|
||||
if not isinstance(entry, dict) or entry.get("slug") != model:
|
||||
continue
|
||||
upgrade = entry.get("upgrade")
|
||||
if not isinstance(upgrade, dict):
|
||||
return None
|
||||
target = upgrade.get("model") or upgrade.get("id")
|
||||
if isinstance(target, str) and target and target != model:
|
||||
return target
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _acknowledge_codex_model_migration(codex_home: Path, model: str, target: str) -> None:
|
||||
"""Suppress one model-migration prompt in a private runner-owned config."""
|
||||
config_path = codex_home / "config.toml"
|
||||
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
||||
document = tomlkit.parse(existing) if existing else tomlkit.document()
|
||||
notice = document.get("notice")
|
||||
if notice is None:
|
||||
notice = tomlkit.table()
|
||||
document["notice"] = notice
|
||||
migrations = notice.get("model_migrations")
|
||||
if migrations is None:
|
||||
migrations = tomlkit.table()
|
||||
notice["model_migrations"] = migrations
|
||||
migrations[model] = target
|
||||
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
def _inject_mcp_server_config(
|
||||
codex_home: Path,
|
||||
bridge_dir: Path,
|
||||
@@ -745,13 +786,18 @@ async def _start_codex_model_discovery_process(
|
||||
listen_url: str,
|
||||
env: dict[str, str],
|
||||
cwd: Path,
|
||||
config_overrides: Sequence[str] = (),
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Start the isolated Codex process used only for model discovery."""
|
||||
override_args: list[str] = []
|
||||
for override in config_overrides:
|
||||
override_args.extend(("-c", override))
|
||||
return await asyncio.create_subprocess_exec(
|
||||
codex_path,
|
||||
"app-server",
|
||||
"--listen",
|
||||
listen_url,
|
||||
*override_args,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
@@ -789,6 +835,189 @@ async def _wait_for_discovery_listener(
|
||||
raise TimeoutError("Timed out waiting for Codex model discovery app-server")
|
||||
|
||||
|
||||
def _probe_codex_home(config_overrides: Sequence[str]) -> Path:
|
||||
"""
|
||||
Persistent probe ``CODEX_HOME`` for one provider configuration.
|
||||
|
||||
Persistent (unlike the hermetic discovery's temp dir) so Codex's own
|
||||
``models_cache.json`` ETag handling makes repeat probes cheap; keyed by
|
||||
the override set so a provider change never replays another provider's
|
||||
cache. The account's real ``auth.json`` is symlinked in, the same way
|
||||
a session launch links it: the credential decides which models the
|
||||
account's catalog lists (login-gated entries, the account default), so
|
||||
a credential-less probe answers for a catalog no session will see.
|
||||
|
||||
:param config_overrides: The probe's ``-c`` overrides.
|
||||
:returns: The created ``CODEX_HOME`` directory.
|
||||
"""
|
||||
key = hashlib.sha256("\n".join(config_overrides).encode("utf-8")).hexdigest()[:12]
|
||||
home = Path.home() / ".omnigent" / "cache" / "codex-model-probe" / key
|
||||
home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
real_auth = _codex_home_config_source_from_env() / "auth.json"
|
||||
probe_auth = home / "auth.json"
|
||||
if real_auth.exists():
|
||||
with contextlib.suppress(OSError):
|
||||
if probe_auth.is_symlink() or probe_auth.exists():
|
||||
probe_auth.unlink()
|
||||
probe_auth.symlink_to(real_auth)
|
||||
return home
|
||||
|
||||
|
||||
def mark_launch_default(rows: list[_JsonObject], pinned_model: str | None) -> list[_JsonObject]:
|
||||
"""
|
||||
Reduce ``model/list`` rows to exactly one ``isDefault`` marker.
|
||||
|
||||
The launch-pinned model wins when a row names it (either spelling);
|
||||
otherwise Codex's own first default stands. Rows are otherwise verbatim.
|
||||
|
||||
Codex's own ``isDefault`` is its built-in preference, which says nothing
|
||||
about the model this session launched on, so a picker that trusted it
|
||||
would name a model the pane is not running.
|
||||
|
||||
:param rows: Raw ``model/list`` rows.
|
||||
:param pinned_model: The model the session runs, or ``None``.
|
||||
:returns: The rows with a single default marked.
|
||||
"""
|
||||
from omnigent.codex_model_vocabulary import comparable_model_id
|
||||
|
||||
codex_default_index: int | None = None
|
||||
pinned_index: int | None = None
|
||||
pinned_key = comparable_model_id(pinned_model) if pinned_model else None
|
||||
marked: list[_JsonObject] = []
|
||||
for index, row in enumerate(rows):
|
||||
cleaned = {key: value for key, value in row.items() if key != "isDefault"}
|
||||
marked.append(cleaned)
|
||||
if codex_default_index is None and row.get("isDefault") is True:
|
||||
codex_default_index = index
|
||||
if pinned_index is None and pinned_key is not None:
|
||||
for spelling in (row.get("id"), row.get("model")):
|
||||
if isinstance(spelling, str) and comparable_model_id(spelling) == pinned_key:
|
||||
pinned_index = index
|
||||
break
|
||||
default_index = pinned_index if pinned_index is not None else codex_default_index
|
||||
if default_index is not None:
|
||||
marked[default_index]["isDefault"] = True
|
||||
return marked
|
||||
|
||||
|
||||
async def probe_codex_model_options(*, codex_path: str | None = None) -> list[_JsonObject]:
|
||||
"""
|
||||
Ask a session-configured Codex app-server for its own model list.
|
||||
|
||||
The harness is the source of truth for what a session's ``/model``
|
||||
picker would offer, so the probe boots ``codex app-server`` with the
|
||||
SAME materialization a session launch gets — for every launch shape.
|
||||
A Databricks profile contributes its provider overrides (gateway base
|
||||
URL + minted auth + model pin) and ``DATABRICKS_HOST``; other provider
|
||||
shapes carry their resolved ``-c`` overrides verbatim; the plain
|
||||
Codex-login shape probes bare, which yields the ACCOUNT's visible
|
||||
catalog: the probe home is isolated (never the user's real
|
||||
``~/.codex``) but links the real ``auth.json`` in the way a session
|
||||
launch does, so login-gated entries and the account default match what
|
||||
a live session will offer.
|
||||
|
||||
:param codex_path: Optional Codex executable override.
|
||||
:returns: The probe rows with a single default marked.
|
||||
:raises ImportError: When the Codex CLI is unavailable.
|
||||
:raises OSError: When a Databricks profile resolves no workspace host.
|
||||
:raises RuntimeError: When the probe app-server exits before connecting.
|
||||
:raises TimeoutError: When the probe app-server does not become ready.
|
||||
"""
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
resolved_codex = codex_path or _find_codex_cli()
|
||||
if not resolved_codex:
|
||||
raise ImportError("Native Codex model probing requires the 'codex' CLI on PATH.")
|
||||
config_overrides = list(launch.config_overrides)
|
||||
pinned_model = launch.model
|
||||
env = _clean_codex_env()
|
||||
if launch.profile is not None:
|
||||
databricks = await asyncio.to_thread(
|
||||
_databricks_launch_materialization, model=launch.model, profile=launch.profile
|
||||
)
|
||||
config_overrides.extend(databricks.config_overrides)
|
||||
env["DATABRICKS_HOST"] = databricks.host
|
||||
pinned_model = databricks.model
|
||||
codex_home = await asyncio.to_thread(_probe_codex_home, config_overrides)
|
||||
env["CODEX_HOME"] = str(codex_home)
|
||||
port = _allocate_loopback_port()
|
||||
listen_url = f"ws://127.0.0.1:{port}"
|
||||
process = await _start_codex_model_discovery_process(
|
||||
codex_path=resolved_codex,
|
||||
listen_url=listen_url,
|
||||
env=env,
|
||||
cwd=codex_home,
|
||||
config_overrides=config_overrides,
|
||||
)
|
||||
client: CodexAppServerClient | None = None
|
||||
try:
|
||||
await _wait_for_discovery_listener(process, port)
|
||||
client = CodexAppServerClient(
|
||||
ws_url=listen_url,
|
||||
client_name="omnigent-codex-model-probe",
|
||||
)
|
||||
await client.connect()
|
||||
rows = await list_codex_model_options(client)
|
||||
finally:
|
||||
if client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await client.close()
|
||||
_proc.terminate_tree(process)
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
_proc.kill_tree(process)
|
||||
await process.wait()
|
||||
return mark_launch_default(rows, pinned_model)
|
||||
|
||||
|
||||
def codex_catalog_fingerprint(launch: NativeCodexLaunch) -> str:
|
||||
"""The launch-config fingerprint keying codex's shared model catalog.
|
||||
|
||||
One formula for every consumer (host boot probe, runner launch, live
|
||||
write-back), so they read and write the same catalog file. Callers
|
||||
fingerprint the SHAPE — a ``model=None`` resolution — so per-session
|
||||
picks never fragment the catalog.
|
||||
|
||||
:param launch: The resolved launch (``resolve_native_codex_launch``).
|
||||
:returns: A stable fingerprint string.
|
||||
"""
|
||||
from omnigent.model_catalog_store import fingerprint_of
|
||||
|
||||
return fingerprint_of(
|
||||
"codex-native", launch.profile, launch.model, tuple(launch.config_overrides)
|
||||
)
|
||||
|
||||
|
||||
async def codex_launch_catalog(*, codex_path: str | None = None) -> list[_JsonObject] | None:
|
||||
"""
|
||||
The shared codex catalog for this host's default shape: store, then probe.
|
||||
|
||||
Reads the on-disk catalog for the ``model=None`` launch shape; a miss
|
||||
pays one session-shaped probe (real auth linked in) and persists the
|
||||
answer for every later consumer.
|
||||
|
||||
:param codex_path: Optional Codex executable override.
|
||||
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
|
||||
"""
|
||||
from omnigent import model_catalog_store
|
||||
|
||||
try:
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
except Exception: # noqa: BLE001 — a broken provider config means no catalog
|
||||
_logger.warning("codex catalog: launch shape resolution failed", exc_info=True)
|
||||
return None
|
||||
fingerprint = codex_catalog_fingerprint(launch)
|
||||
|
||||
async def _probe() -> list[_JsonObject] | None:
|
||||
try:
|
||||
return await probe_codex_model_options(codex_path=codex_path)
|
||||
except Exception: # noqa: BLE001 — probe failure means "no catalog", never a crash
|
||||
_logger.warning("codex catalog probe failed", exc_info=True)
|
||||
return None
|
||||
|
||||
return await model_catalog_store.ensure_catalog("codex-native", fingerprint, _probe)
|
||||
|
||||
|
||||
def _build_native_codex_app_server_argv(
|
||||
*,
|
||||
tagged_argv0: str,
|
||||
@@ -920,6 +1149,15 @@ class CodexNativeAppServer:
|
||||
self.router_hooks_registered = router_bridge_dir is not None and policy_hooks_supported
|
||||
routed_spawns = router_bridge_dir is not None
|
||||
config_source = _codex_home_config_source_from_env()
|
||||
model_migration_target: str | None = None
|
||||
if self.trust_project and self.pinned_model:
|
||||
catalog = await asyncio.to_thread(
|
||||
read_codex_model_catalog,
|
||||
self.codex_path,
|
||||
config_source,
|
||||
timeout=_MODEL_MIGRATION_CATALOG_TIMEOUT_SECONDS,
|
||||
)
|
||||
model_migration_target = _codex_model_upgrade_target(catalog, self.pinned_model)
|
||||
# Off the loop: this copies/symlinks a home AND (on a Smart Routing
|
||||
# session) shells out to ``codex debug models`` with a 10s timeout. Run
|
||||
# inline it stalled every other session sharing this event loop for that
|
||||
@@ -944,6 +1182,12 @@ class CodexNativeAppServer:
|
||||
)
|
||||
if self.pinned_model:
|
||||
_pin_codex_config_model(self.codex_home, self.pinned_model)
|
||||
if model_migration_target is not None:
|
||||
_acknowledge_codex_model_migration(
|
||||
self.codex_home,
|
||||
self.pinned_model,
|
||||
model_migration_target,
|
||||
)
|
||||
_sync_codex_developer_instructions(
|
||||
self.codex_home,
|
||||
self.developer_instructions,
|
||||
@@ -1722,6 +1966,130 @@ def _trust_codex_project(codex_home: Path, cwd: Path) -> None:
|
||||
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DatabricksLaunchMaterialization:
|
||||
"""
|
||||
The Databricks-profile pieces of a Codex launch, shared by the real
|
||||
app-server build and the model-options probe so the two cannot drift.
|
||||
|
||||
:param config_overrides: ``-c`` overrides routing Codex through the
|
||||
profile's AI Gateway (provider block + auth command + model pin).
|
||||
:param model: The model the overrides pin, e.g. ``"databricks-gpt-5-4"``
|
||||
— the explicit *model* when given, else the catalog default.
|
||||
:param host: The profile's workspace origin for ``DATABRICKS_HOST``.
|
||||
"""
|
||||
|
||||
config_overrides: list[str]
|
||||
model: str
|
||||
host: str
|
||||
|
||||
|
||||
def _databricks_launch_materialization(
|
||||
*, model: str | None, profile: str
|
||||
) -> _DatabricksLaunchMaterialization:
|
||||
"""
|
||||
Resolve the Databricks-profile routing pieces of a Codex launch.
|
||||
|
||||
Uses the profile's own host so the gateway base URL matches the token
|
||||
the profile-pinned auth command mints; a ``DATABRICKS_HOST`` override in
|
||||
the runner env must not point the base URL at another workspace.
|
||||
|
||||
:param model: Optional explicit model pin; ``None`` resolves the
|
||||
catalog default.
|
||||
:param profile: ``~/.databrickscfg`` profile name, e.g. ``"oss"``.
|
||||
:returns: The materialized overrides, pinned model, and host.
|
||||
:raises OSError: When the profile resolves no workspace host.
|
||||
"""
|
||||
host = _databricks_gateway_host(profile)
|
||||
if not host:
|
||||
raise OSError(
|
||||
f"Native Codex with Databricks profile {profile!r} (from your "
|
||||
"provider config) requires a matching ~/.databrickscfg section "
|
||||
"with a host visible to the runner process."
|
||||
)
|
||||
host = host.rstrip("/")
|
||||
# Resolve against what the workspace actually serves (live UC listing →
|
||||
# ucode state → bundled catalog), never the bundled catalog alone — its
|
||||
# legacy ``databricks-`` spellings can 501 on today's gateway.
|
||||
resolved_model = _resolve_databricks_codex_model(host, profile, model)
|
||||
return _DatabricksLaunchMaterialization(
|
||||
config_overrides=_databricks_codex_config_overrides(
|
||||
model=resolved_model,
|
||||
base_url=_databricks_codex_base_url(host),
|
||||
auth_command=_databricks_codex_auth_command(host, profile),
|
||||
),
|
||||
model=resolved_model,
|
||||
host=host,
|
||||
)
|
||||
|
||||
|
||||
# DATABRICKS-PATCH(codex-live-model-discovery)
|
||||
def _resolve_databricks_codex_model(host: str, profile: str, requested: str | None) -> str:
|
||||
"""Resolve the codex launch model against what the workspace serves.
|
||||
|
||||
Codex used to take its model from the bundled MLflow catalog — a
|
||||
third-party listing whose Databricks ids carry the legacy
|
||||
``databricks-`` spelling the gateway now answers with ``501
|
||||
NOT_IMPLEMENTED ... Use Unity Catalog model services (v3)`` — so a launch
|
||||
could pin a model the workspace will not serve. Resolve from the workspace
|
||||
instead, as claude-native already does: the live Unity Catalog listing,
|
||||
then ucode's cached copy of it, then the bundled catalog as the documented
|
||||
last resort.
|
||||
|
||||
An explicit model is matched against the servable ids, so a legacy
|
||||
``model_override`` persisted before this change still launches; one the
|
||||
workspace does not serve passes through untouched, because the gateway's
|
||||
error beats a silent substitution.
|
||||
|
||||
:param host: Workspace origin, e.g. ``"https://example.com"``.
|
||||
:param profile: Databricks CLI profile backing the launch.
|
||||
:param requested: Explicit model id, or ``None`` to take the newest
|
||||
servable one.
|
||||
:returns: The model id to pin on the codex launch.
|
||||
"""
|
||||
from omnigent.databricks_model_discovery import (
|
||||
discover_databricks_codex_models,
|
||||
select_servable_model,
|
||||
)
|
||||
|
||||
servable: tuple[str, ...] = ()
|
||||
try:
|
||||
from omnigent.runtime.credentials.databricks import resolve_databricks_workspace
|
||||
|
||||
creds = resolve_databricks_workspace(profile)
|
||||
# Discover against the host the launch actually posts to. This resolver
|
||||
# honors ``DATABRICKS_HOST`` while the launch host comes from the
|
||||
# profile section alone (``_databricks_gateway_host``), so using
|
||||
# ``creds.host`` here can pin a model discovered on workspace A onto a
|
||||
# launch targeting workspace B. A token that does not match ``host``
|
||||
# simply fails the listing and drops to the ucode-state fallback below,
|
||||
# which is already keyed by ``host``.
|
||||
servable = discover_databricks_codex_models(host, creds.token)
|
||||
except Exception: # noqa: BLE001 — cached ucode state is the launch fallback
|
||||
_logger.warning(
|
||||
"native-codex: live Databricks model discovery failed for profile %r; "
|
||||
"falling back to ucode state",
|
||||
profile,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from omnigent.onboarding.ucode_state import read_ucode_state
|
||||
|
||||
workspace_state = read_ucode_state(host)
|
||||
if workspace_state is not None:
|
||||
servable = tuple(workspace_state.codex_models)
|
||||
except Exception: # noqa: BLE001 — the bundled catalog is the last resort
|
||||
_logger.warning(
|
||||
"native-codex: could not read ucode state for %r", profile, exc_info=True
|
||||
)
|
||||
|
||||
if requested:
|
||||
return select_servable_model(requested, servable) or requested
|
||||
if servable:
|
||||
return servable[0]
|
||||
return model_catalog.resolve_catalog_model("databricks", family="openai").model_id
|
||||
|
||||
|
||||
def build_codex_native_server(
|
||||
*,
|
||||
socket_path: Path,
|
||||
@@ -1789,27 +2157,18 @@ def build_codex_native_server(
|
||||
)
|
||||
env = _clean_codex_env()
|
||||
config_overrides: list[str] = []
|
||||
pinned_model = model
|
||||
if profile is not None:
|
||||
# Use the profile's own host so the gateway base URL matches the token
|
||||
# the profile-pinned auth command mints; a DATABRICKS_HOST override in
|
||||
# the runner env must not point the base URL at another workspace.
|
||||
host = _databricks_gateway_host(profile)
|
||||
if not host:
|
||||
raise OSError(
|
||||
f"Native Codex with Databricks profile {profile!r} (from your "
|
||||
"provider config) requires a matching ~/.databrickscfg section "
|
||||
"with a host visible to the runner process."
|
||||
)
|
||||
host = host.rstrip("/")
|
||||
config_overrides.extend(
|
||||
_databricks_codex_config_overrides(
|
||||
model=model
|
||||
or model_catalog.resolve_catalog_model("databricks", family="openai").model_id,
|
||||
base_url=_databricks_codex_base_url(host),
|
||||
auth_command=_databricks_codex_auth_command(host, profile),
|
||||
)
|
||||
)
|
||||
env["DATABRICKS_HOST"] = host
|
||||
databricks = _databricks_launch_materialization(model=model, profile=profile)
|
||||
config_overrides.extend(databricks.config_overrides)
|
||||
env["DATABRICKS_HOST"] = databricks.host
|
||||
# A launch that names no model still routes through the profile's
|
||||
# resolved model via ``-c model=``, which outranks the config.toml
|
||||
# copied from the user's shared home. Pin that model in codex's own
|
||||
# spelling — the vocabulary config.toml and its readers use — so the
|
||||
# forwarder mirror and cost gate report the model this session runs
|
||||
# instead of whatever the shared file was last left on.
|
||||
pinned_model = codex_spawn_model(databricks.model) or databricks.model
|
||||
if extra_config_overrides:
|
||||
config_overrides.extend(extra_config_overrides)
|
||||
if bypass_sandbox:
|
||||
@@ -1823,6 +2182,15 @@ def build_codex_native_server(
|
||||
'sandbox_mode="danger-full-access"',
|
||||
]
|
||||
)
|
||||
# Every launch is explicit: the resolved model rides argv (``-c model=``)
|
||||
# AND the private config copy's ``model =`` line (pinned in ``start``),
|
||||
# both written from this one value — so a stale line copied from the
|
||||
# user's shared config can never govern a session, and the two artifacts
|
||||
# cannot drift.
|
||||
if pinned_model and not any(
|
||||
override.split("=", 1)[0] == "model" for override in config_overrides
|
||||
):
|
||||
config_overrides.append(f"model={json.dumps(pinned_model)}")
|
||||
return CodexNativeAppServer(
|
||||
codex_path=resolved_codex,
|
||||
socket_path=socket_path,
|
||||
@@ -1835,7 +2203,7 @@ def build_codex_native_server(
|
||||
ap_server_url=ap_server_url,
|
||||
ap_auth_headers=ap_auth_headers,
|
||||
python_executable=python_executable,
|
||||
pinned_model=model,
|
||||
pinned_model=pinned_model,
|
||||
trust_project=trust_project,
|
||||
)
|
||||
|
||||
@@ -2283,8 +2651,9 @@ def resolve_native_codex_launch(
|
||||
config (issue #2744 — parity with the in-process codex harness).
|
||||
:returns: The resolved :class:`NativeCodexLaunch`.
|
||||
"""
|
||||
from omnigent.onboarding.ambient import codex_config_detection
|
||||
from omnigent.onboarding.detected import (
|
||||
codex_config_provider_dismissed,
|
||||
dismissed_detection_names,
|
||||
effective_config_with_detected,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import (
|
||||
@@ -2296,14 +2665,17 @@ def resolve_native_codex_launch(
|
||||
from omnigent.spec.types import DatabricksAuth
|
||||
|
||||
explicit = load_config()
|
||||
config_detection = codex_config_detection()
|
||||
config_provider_dismissed = (
|
||||
config_detection is not None
|
||||
and config_detection.name in dismissed_detection_names(explicit)
|
||||
)
|
||||
# When the launch ends up on codex's own login with NO provider routing,
|
||||
# the bridged config.toml's custom default model_provider would still
|
||||
# apply — including one the user explicitly Removed (dismissed). Pin
|
||||
# codex's built-in provider in that case so the dismissal holds at run
|
||||
# time. An undetectable/undismissed custom provider keeps its routing.
|
||||
no_provider_overrides = (
|
||||
['model_provider="openai"'] if codex_config_provider_dismissed(explicit) else []
|
||||
)
|
||||
no_provider_overrides = ['model_provider="openai"'] if config_provider_dismissed else []
|
||||
if spec is not None and (
|
||||
spec.executor.auth is not None
|
||||
or spec.executor.profile
|
||||
@@ -2384,6 +2756,31 @@ def resolve_native_codex_launch(
|
||||
)
|
||||
entry = default_provider_for_harness(effective_config_with_detected(explicit), "codex")
|
||||
|
||||
if (
|
||||
entry is None
|
||||
and config_detection is not None
|
||||
and config_detection.model_provider is not None
|
||||
and not config_provider_dismissed
|
||||
):
|
||||
# An adopted cli-config entry can explicitly shadow the same ambient
|
||||
# detection without being marked the Omnigent default. Codex still
|
||||
# selects that provider from config.toml, so pin the already-resolved
|
||||
# detection instead of describing this as an OpenAI-login launch.
|
||||
# This keeps rollout metadata, app-server, and remote TUI routing on
|
||||
# one immutable provider selection during cold resume.
|
||||
provider_id = config_detection.model_provider
|
||||
_logger.info(
|
||||
"native-codex routing: config.toml provider %r (ambient fallback, model=%s)",
|
||||
provider_id,
|
||||
model,
|
||||
)
|
||||
return NativeCodexLaunch(
|
||||
config_overrides=[f"model_provider={json.dumps(provider_id)}"],
|
||||
model=model,
|
||||
profile=None,
|
||||
summary=f"Codex config.toml provider {provider_id!r} (ambient fallback)",
|
||||
)
|
||||
|
||||
if entry is None:
|
||||
_logger.info(
|
||||
"native-codex routing: Codex CLI login (no provider configured for the Codex "
|
||||
|
||||
@@ -76,6 +76,8 @@ class CodexNativeBridgeState:
|
||||
``"0196..."``.
|
||||
:param codex_home: Private per-session ``CODEX_HOME`` path, e.g.
|
||||
``"/home/user/.omnigent/codex-native/x/codex-home"``.
|
||||
:param cwd: Native Codex thread working directory, e.g.
|
||||
``"/home/user/project"``.
|
||||
:param active_turn_id: Current Codex turn id, if one is running,
|
||||
e.g. ``"turn_abc123"``.
|
||||
"""
|
||||
@@ -85,6 +87,7 @@ class CodexNativeBridgeState:
|
||||
thread_id: str
|
||||
codex_home: str
|
||||
active_turn_id: str | None = None
|
||||
cwd: str | None = None
|
||||
|
||||
|
||||
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
|
||||
@@ -339,9 +342,23 @@ def read_codex_config_model(bridge_dir: Path) -> str | None:
|
||||
:returns: The top-level ``model`` from ``config.toml`` (e.g.
|
||||
``"gpt-5.4"``), or ``None`` when undeterminable.
|
||||
"""
|
||||
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
|
||||
return read_codex_home_config_model(codex_home_for_bridge_dir(bridge_dir))
|
||||
|
||||
|
||||
def read_codex_home_config_model(codex_home: Path) -> str | None:
|
||||
"""
|
||||
Read the active model straight from a session's ``CODEX_HOME``.
|
||||
|
||||
Same value and fail-safe behaviour as :func:`read_codex_config_model`,
|
||||
for callers that hold the ``CODEX_HOME`` path (e.g. a live bridge
|
||||
state) rather than the bridge directory.
|
||||
|
||||
:param codex_home: The session's private ``CODEX_HOME`` directory.
|
||||
:returns: The top-level ``model`` from ``config.toml`` (e.g.
|
||||
``"gpt-5.4"``), or ``None`` when undeterminable.
|
||||
"""
|
||||
try:
|
||||
data = tomllib.loads(config_path.read_text())
|
||||
data = tomllib.loads((codex_home / "config.toml").read_text())
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
model = data.get("model")
|
||||
@@ -425,6 +442,7 @@ def write_bridge_state(bridge_dir: Path, state: CodexNativeBridgeState) -> None:
|
||||
"thread_id": state.thread_id,
|
||||
"codex_home": state.codex_home,
|
||||
"active_turn_id": state.active_turn_id,
|
||||
"cwd": state.cwd,
|
||||
},
|
||||
handle,
|
||||
sort_keys=True,
|
||||
@@ -686,6 +704,7 @@ def read_bridge_state(bridge_dir: Path) -> CodexNativeBridgeState | None:
|
||||
thread_id = raw.get("thread_id")
|
||||
codex_home = raw.get("codex_home")
|
||||
active_turn_id = raw.get("active_turn_id")
|
||||
cwd = raw.get("cwd")
|
||||
if (
|
||||
not isinstance(session_id, str)
|
||||
or not session_id
|
||||
@@ -706,6 +725,7 @@ def read_bridge_state(bridge_dir: Path) -> CodexNativeBridgeState | None:
|
||||
thread_id=thread_id,
|
||||
codex_home=codex_home,
|
||||
active_turn_id=parsed_active_turn_id,
|
||||
cwd=cwd if isinstance(cwd, str) and cwd else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -729,6 +749,7 @@ def update_active_turn_id(bridge_dir: Path, active_turn_id: str | None) -> None:
|
||||
thread_id=state.thread_id,
|
||||
codex_home=state.codex_home,
|
||||
active_turn_id=active_turn_id,
|
||||
cwd=state.cwd,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -757,6 +778,7 @@ def update_thread_id(bridge_dir: Path, thread_id: str, active_turn_id: str | Non
|
||||
thread_id=thread_id,
|
||||
codex_home=state.codex_home,
|
||||
active_turn_id=active_turn_id,
|
||||
cwd=state.cwd,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -802,6 +824,7 @@ def clear_active_turn_id_if_matches(bridge_dir: Path, completed_turn_id: str | N
|
||||
thread_id=state.thread_id,
|
||||
codex_home=state.codex_home,
|
||||
active_turn_id=None,
|
||||
cwd=state.cwd,
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -192,14 +192,10 @@ _CODEX_ELICITATION_REQUEST_METHODS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Turn-error surfacing. A failed Codex turn arrives as ``turn/completed``
|
||||
# (or ``turn/failed``) with ``turn.status == "failed"`` and a ``turn.error``
|
||||
# object ``{message, codexErrorInfo?, additionalDetails?}``; keying status off
|
||||
# the method alone mapped such turns to ``idle`` — a "silent success". The
|
||||
# forwarder inspects ``turn.status``/``turn.error``, forces ``failed``, and
|
||||
# surfaces the reason. As a fallback it also catches an ``error`` ThreadItem in
|
||||
# ``turn.items``: both shapes exist in the app-server type system and the wire
|
||||
# shape varies by version, so detecting either keeps the fix robust.
|
||||
# Turn-error surfacing. Codex reports failures through a standalone ``error``
|
||||
# notification and on terminal turn boundaries via ``turn.error`` / failed
|
||||
# status. The forwarder handles both, plus the older ``error`` ThreadItem
|
||||
# fallback, so every non-retrying failure reaches the session UI.
|
||||
#
|
||||
# ``codexErrorInfo`` is the app-server's structured classification (e.g.
|
||||
# ``unauthorized``, ``usage_limit_exceeded``); auth-class values get a re-auth
|
||||
@@ -359,6 +355,9 @@ class _CodexForwarderState:
|
||||
:param synced_item_keys: Stable item keys already posted to Omnigent this
|
||||
connection, e.g. ``{"thread_c:turn_c:item-1"}``. In-memory only;
|
||||
guards replay-vs-live overlap within one forwarder lifetime.
|
||||
:param surfaced_terminal_error_turns: Turn ids whose standalone terminal
|
||||
``error`` notification was already surfaced. Used to suppress a later
|
||||
terminal boundary for the same turn.
|
||||
:param posted_user_turns: Turn ids whose ``userMessage`` has been
|
||||
posted to Omnigent this connection, e.g. ``{"turn_123"}``. Used to
|
||||
enforce user-before-assistant ordering: before posting a turn's
|
||||
@@ -406,6 +405,7 @@ class _CodexForwarderState:
|
||||
pending_child_threads: dict[str, str | None] = field(default_factory=dict)
|
||||
subscribed_child_threads: set[str] = field(default_factory=set)
|
||||
synced_item_keys: set[str] = field(default_factory=set)
|
||||
surfaced_terminal_error_turns: set[str] = field(default_factory=set)
|
||||
posted_user_turns: set[str] = field(default_factory=set)
|
||||
posted_tool_calls: set[str] = field(default_factory=set)
|
||||
partial_text_by_turn: dict[str, list[_PartialTextBuffer]] = field(default_factory=dict)
|
||||
@@ -1006,6 +1006,15 @@ def _terminal_error_from_turn(params: _JsonObject) -> _CodexTerminalError | None
|
||||
return _CodexTerminalError(message=message, kind=_classify_codex_error(payload, message))
|
||||
|
||||
|
||||
def _terminal_error_from_notification(params: _JsonObject) -> _CodexTerminalError | None:
|
||||
"""Return the failure carried by Codex's standalone ``error`` notification."""
|
||||
payload = params.get("error")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
message = _error_payload_message(payload)
|
||||
return _CodexTerminalError(message=message, kind=_classify_codex_error(payload, message))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CodexTurnStatusEdge:
|
||||
"""
|
||||
@@ -2985,6 +2994,41 @@ async def _maybe_handle_turn_event(
|
||||
:param forwarder_state: Optional forwarder state.
|
||||
:returns: ``True`` when this event was handled.
|
||||
"""
|
||||
if method == "error":
|
||||
if params.get("willRetry") is True:
|
||||
_logger.info(
|
||||
"Codex forwarder observed retryable turn error: turn_id=%s",
|
||||
_turn_id_from_payload(params),
|
||||
)
|
||||
return True
|
||||
if delta_coalescer is not None:
|
||||
await delta_coalescer.flush()
|
||||
error = _terminal_error_from_notification(params)
|
||||
if error is None:
|
||||
_logger.warning("Codex forwarder ignored malformed error notification")
|
||||
return True
|
||||
turn_id = _turn_id_from_payload(params)
|
||||
if forwarder_state is not None and turn_id is not None:
|
||||
if turn_id in forwarder_state.surfaced_terminal_error_turns:
|
||||
_logger.info(
|
||||
"Codex forwarder ignored duplicate terminal error: turn_id=%s",
|
||||
turn_id,
|
||||
)
|
||||
return True
|
||||
forwarder_state.surfaced_terminal_error_turns.add(turn_id)
|
||||
clear_active_turn_id_if_matches(bridge_dir, turn_id)
|
||||
await _post_turn_status_edge(
|
||||
client,
|
||||
session_id,
|
||||
_CodexTurnStatusEdge(
|
||||
status="failed",
|
||||
turn_id=turn_id,
|
||||
source="error",
|
||||
error=error,
|
||||
),
|
||||
)
|
||||
await usage_coalescer.flush()
|
||||
return True
|
||||
if method == "turn/started":
|
||||
if delta_coalescer is not None:
|
||||
await delta_coalescer.flush()
|
||||
@@ -3230,7 +3274,14 @@ async def _handle_terminal_turn_boundary(
|
||||
params=params,
|
||||
forwarder_state=forwarder_state,
|
||||
)
|
||||
handled = await _handle_terminal_turn_event(client, session_id, bridge_dir, method, params)
|
||||
handled = await _handle_terminal_turn_event(
|
||||
client,
|
||||
session_id,
|
||||
bridge_dir,
|
||||
method,
|
||||
params,
|
||||
forwarder_state=forwarder_state,
|
||||
)
|
||||
if handled:
|
||||
await elicitation_tracker.resolve_by_terminal_turn_event(
|
||||
client,
|
||||
@@ -4124,21 +4175,38 @@ async def _handle_terminal_turn_event(
|
||||
bridge_dir: Path,
|
||||
method: str,
|
||||
params: _JsonObject,
|
||||
*,
|
||||
forwarder_state: _CodexForwarderState | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Forward a terminal-observed Codex turn completion/failure event.
|
||||
Handle a terminal-observed Codex turn completion/failure event.
|
||||
|
||||
:param client: HTTP client for Omnigent event posts.
|
||||
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:param method: Codex method, e.g. ``"turn/completed"``.
|
||||
:param params: Codex turn event params.
|
||||
:returns: ``True`` when the terminal event belonged to the active
|
||||
turn and was forwarded, ``False`` when it was stale.
|
||||
:param forwarder_state: Optional connection state used to suppress a
|
||||
terminal boundary whose standalone error was already surfaced.
|
||||
:returns: ``True`` when the terminal event belonged to the active turn
|
||||
and its lifecycle was handled, ``False`` when it was stale.
|
||||
"""
|
||||
terminal_turn_id = _terminal_turn_id_from_params(params)
|
||||
if (
|
||||
forwarder_state is not None
|
||||
and terminal_turn_id is not None
|
||||
and terminal_turn_id in forwarder_state.surfaced_terminal_error_turns
|
||||
):
|
||||
clear_active_turn_id_if_matches(bridge_dir, terminal_turn_id)
|
||||
_logger.info(
|
||||
"Codex forwarder suppressed terminal boundary after standalone error: "
|
||||
"method=%s turn_id=%s",
|
||||
method,
|
||||
terminal_turn_id,
|
||||
)
|
||||
return True
|
||||
edge = _terminal_turn_status_edge(bridge_dir, method, params)
|
||||
if edge is None:
|
||||
terminal_turn_id = _terminal_turn_id_from_params(params)
|
||||
_logger.info(
|
||||
"Codex forwarder ignored stale terminal turn event: method=%s turn_id=%s",
|
||||
method,
|
||||
@@ -6278,7 +6346,9 @@ def _session_usage_data_from_params(params: _JsonObject) -> dict[str, int] | Non
|
||||
if not isinstance(total, dict):
|
||||
return None
|
||||
cumulative_input_tokens = total.get("inputTokens")
|
||||
context_window = total.get("contextWindow")
|
||||
context_window = token_usage.get("modelContextWindow")
|
||||
if not isinstance(context_window, int) or context_window <= 0:
|
||||
context_window = total.get("contextWindow")
|
||||
output_tokens = total.get("outputTokens")
|
||||
cached_input_tokens = total.get("cachedInputTokens")
|
||||
data: dict[str, int] = {}
|
||||
|
||||
@@ -20,6 +20,8 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.process_logging import data_dir
|
||||
|
||||
_STATE_ROOT_ENV_VAR = "OMNIGENT_CODEX_NATIVE_STATE_DIR"
|
||||
_logger = logging.getLogger(__name__)
|
||||
_LAUNCH_FILE = "launch.json"
|
||||
@@ -44,14 +46,15 @@ def _codex_native_state_root() -> Path:
|
||||
Return the root directory for persistent codex-native state.
|
||||
|
||||
Honors :data:`_STATE_ROOT_ENV_VAR` for tests and advanced local
|
||||
setups. Production defaults to ``~/.omnigent/codex-native``.
|
||||
setups. Otherwise follows ``OMNIGENT_DATA_DIR``, falling back to
|
||||
``~/.omnigent/codex-native``.
|
||||
|
||||
:returns: Absolute path to the state root.
|
||||
"""
|
||||
override = os.environ.get(_STATE_ROOT_ENV_VAR)
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path.home() / ".omnigent" / "codex-native"
|
||||
return data_dir() / "codex-native"
|
||||
|
||||
|
||||
def _state_dir_for_conversation_id(conversation_id: str) -> Path:
|
||||
|
||||
@@ -511,10 +511,13 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Cursor session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Cursor session...")
|
||||
session_id = await _create_cursor_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_cursor_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
# Persist the model pin before the runner binds and launches the
|
||||
# TUI (it reads model_override from the snapshot to build --model).
|
||||
@@ -575,7 +578,8 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
_update_startup_progress(startup_progress, "Updating Cursor session...")
|
||||
await _patch_cursor_session(client, session_id, patch)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -31,6 +31,11 @@ _HTTP_TIMEOUT_S = 10.0
|
||||
#: a model the same way no matter which listing answered.
|
||||
_CATALOG_SPELLINGS: tuple[str, ...] = ("databricks-", _SYSTEM_MODEL_PREFIX)
|
||||
|
||||
# DATABRICKS-PATCH(codex-live-model-discovery)
|
||||
#: ``gpt-5-6-sol`` → ``("gpt", "5", "6", "sol")``. Mirrors
|
||||
#: ``codex_model_vocabulary._GPT_ID_RE`` without reaching into its privates.
|
||||
_GPT_VERSIONED_ID_RE = re.compile(r"^(gpt|codex)-(\d+)-(\d+)(?:-([a-z0-9]+))?$")
|
||||
|
||||
|
||||
def _bare_model_id(model_id: str) -> str:
|
||||
"""Strip the catalog spelling so ids compare across vocabularies."""
|
||||
@@ -310,3 +315,86 @@ def discover_databricks_claude_models(
|
||||
token,
|
||||
transport=transport,
|
||||
).families
|
||||
|
||||
|
||||
# DATABRICKS-PATCH(codex-live-model-discovery)
|
||||
def discover_databricks_codex_models(
|
||||
workspace_url: str,
|
||||
token: str,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Discover every codex-compatible model a Databricks workspace serves.
|
||||
|
||||
Unity Catalog model services is the only listing that reports what the
|
||||
codex Responses route will serve, so ids are ``system.ai.`` by
|
||||
construction — unlike the Claude catalog above, which also has a legacy
|
||||
gateway listing to merge in.
|
||||
|
||||
:param workspace_url: Workspace origin, e.g. ``"https://example.com"``.
|
||||
:param token: Workspace bearer token.
|
||||
:param transport: Optional HTTP transport used by tests.
|
||||
:returns: Codex-servable model ids, best default first, e.g.
|
||||
``("system.ai.gpt-5-6-sol", "system.ai.gpt-5-5")``. An empty tuple is
|
||||
authoritative: the listing answered and exposes no codex model.
|
||||
:raises httpx.HTTPError: When the listing cannot be read.
|
||||
:raises ValueError: When the listing is malformed.
|
||||
"""
|
||||
from omnigent.model_override import is_codex_compatible_model
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with httpx.Client(transport=transport, timeout=_HTTP_TIMEOUT_S) as client:
|
||||
model_ids = _list_model_service_ids(client, workspace_url, headers)
|
||||
codex_ids = [model_id for model_id in model_ids if is_codex_compatible_model(model_id)]
|
||||
return tuple(sorted(codex_ids, key=_codex_preference_rank, reverse=True))
|
||||
|
||||
|
||||
def _codex_preference_rank(model_id: str) -> tuple[int, int, int, int, str]:
|
||||
"""Order codex-servable ids so the best launch default sorts first.
|
||||
|
||||
The listing says what a workspace *can* serve, not which to start on, and a
|
||||
name sort has no opinion either (it ranks ``kimi-k3`` over every GPT).
|
||||
Tiers, highest first: the owned curated codex catalog in its declared
|
||||
cheapest-safe-first order; then versioned ``gpt``/``codex`` ids, newest
|
||||
generation first and untiered ahead of a same-generation tier; then the
|
||||
rest by name, for determinism.
|
||||
|
||||
:param model_id: A servable id, e.g. ``"system.ai.gpt-5-6-sol"``.
|
||||
:returns: A sort key; compare descending.
|
||||
"""
|
||||
from omnigent.codex_model_vocabulary import comparable_model_id
|
||||
from omnigent.model_fallbacks import static_model_fallback
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
|
||||
bare = comparable_model_id(model_id)
|
||||
curated = static_model_fallback(SUBSCRIPTION_KIND, "codex")
|
||||
order = [comparable_model_id(m) for m in (curated.model_ids if curated else ())]
|
||||
if bare in order:
|
||||
# Negated so the earliest curated entry sorts highest under reverse=True.
|
||||
return (2, -order.index(bare), 0, 0, "")
|
||||
match = _GPT_VERSIONED_ID_RE.match(bare)
|
||||
if match is None:
|
||||
return (0, 0, 0, 0, bare)
|
||||
_family, major, minor, tier = match.groups()
|
||||
return (1, int(major), int(minor), 0 if tier else 1, tier or "")
|
||||
|
||||
|
||||
def select_servable_model(requested: str, servable: Iterable[str]) -> str | None:
|
||||
"""Resolve *requested* against the ids a workspace actually serves.
|
||||
|
||||
Compared on the bare id, so a request naming the legacy ``databricks-``
|
||||
spelling resolves to the ``system.ai.`` id serving that same model — only
|
||||
the served spelling is routable.
|
||||
|
||||
:param requested: Model id in either vocabulary, e.g.
|
||||
``"databricks-gpt-5-6-luna"``.
|
||||
:param servable: Ids the workspace serves, e.g. the result of
|
||||
:func:`discover_databricks_codex_models`.
|
||||
:returns: The servable id for *requested*, or ``None`` when the workspace
|
||||
serves no such model.
|
||||
"""
|
||||
wanted = _bare_model_id(requested)
|
||||
for model_id in servable:
|
||||
if _bare_model_id(model_id) == wanted:
|
||||
return model_id
|
||||
return None
|
||||
|
||||
@@ -624,6 +624,7 @@ class SqlConversationMetadata(OmnigentBase):
|
||||
# No FK: host records are managed outside this table.
|
||||
host_id: Mapped[str | None] = mapped_column(Uuid16(), nullable=True)
|
||||
sub_agent_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
task_summary: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
external_session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
session_state: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
session_usage: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"""Add task_summary to conversation metadata.
|
||||
|
||||
Revision ID: za2b3c4d5e6f
|
||||
Revises: d5e9f1a2b3c4
|
||||
Create Date: 2026-08-10 00:00:00.000000
|
||||
|
||||
Adds a nullable ``task_summary`` column to ``omnigent_conversation_metadata``.
|
||||
Sub-agent sessions use this column to store a human-readable, task-derived label
|
||||
(e.g. "Investigate auth token refresh") generated asynchronously by the
|
||||
background title coordinator. The structured title
|
||||
(``"{agent_type}:{agent_type}-{ordinal}"``) stays in ``conversations.title`` as
|
||||
the stable spawn-or-continue key; ``task_summary`` is purely presentational.
|
||||
|
||||
Additive. No existing data needs backfill.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "za2b3c4d5e6f"
|
||||
down_revision: str | None = "d5e9f1a2b3c4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add ``task_summary`` to ``omnigent_conversation_metadata``."""
|
||||
op.add_column(
|
||||
"omnigent_conversation_metadata",
|
||||
sa.Column("task_summary", sa.String(128), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove ``task_summary`` from ``omnigent_conversation_metadata``."""
|
||||
with op.batch_alter_table("omnigent_conversation_metadata") as batch_op:
|
||||
batch_op.drop_column("task_summary")
|
||||
@@ -10,6 +10,7 @@ import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -553,6 +554,54 @@ def clear_engine_cache() -> None:
|
||||
# ── Managed session ────────────────────────────────────
|
||||
|
||||
|
||||
# Ambient per-engine sessions for a read-only "share one checkout" scope. When
|
||||
# active (see :func:`shared_read_scope`), ``managed_session()`` reuses the
|
||||
# scope's session for its engine instead of opening a fresh pool checkout,
|
||||
# collapsing several back-to-back reads (e.g. the access-control check's
|
||||
# permission + conversation lookups) into a single connection round-trip.
|
||||
# Keyed by ``id(engine)`` so distinct engines (split-DB) still get independent
|
||||
# checkouts. Unset outside a scope, so it is a strict no-op for every ordinary
|
||||
# caller.
|
||||
_shared_read_sessions: ContextVar[dict[int, Session] | None] = ContextVar(
|
||||
"omnigent_shared_read_sessions", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def shared_read_scope() -> Iterator[None]:
|
||||
"""Collapse back-to-back reads into one pool checkout per engine.
|
||||
|
||||
Within this scope, ``managed_session()`` reuses a single session per
|
||||
engine rather than checking out a fresh pooled connection (plus a
|
||||
``pool_pre_ping`` round-trip) on every store call. Intended for a short,
|
||||
strictly READ-ONLY burst — an access-control check, a snapshot assembly —
|
||||
where the per-call checkout dominates the actual query time.
|
||||
|
||||
Nesting reuses the outer scope. Write makers (``immediate=True``) never
|
||||
participate, so they keep their own ``BEGIN IMMEDIATE`` isolation even
|
||||
when nested here. Never hold this open across network I/O: it pins a
|
||||
pooled connection for the scope's whole duration.
|
||||
"""
|
||||
if _shared_read_sessions.get() is not None:
|
||||
# Already inside a scope — the outer one owns the sessions.
|
||||
yield
|
||||
return
|
||||
sessions: dict[int, Session] = {}
|
||||
token = _shared_read_sessions.set(sessions)
|
||||
try:
|
||||
yield
|
||||
for session in sessions.values():
|
||||
session.commit()
|
||||
except BaseException:
|
||||
for session in sessions.values():
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
for session in sessions.values():
|
||||
session.close()
|
||||
_shared_read_sessions.reset(token)
|
||||
|
||||
|
||||
def make_managed_session_maker(
|
||||
engine: Engine,
|
||||
*,
|
||||
@@ -592,7 +641,27 @@ def make_managed_session_maker(
|
||||
Commits on clean exit, rolls back on exception. For SQLite
|
||||
backends, enables foreign key enforcement and sets a
|
||||
busy timeout before yielding.
|
||||
|
||||
Inside a :func:`shared_read_scope` (and only for read makers), the
|
||||
scope's per-engine session is reused instead of a fresh checkout;
|
||||
the scope — not this block — owns its commit/close.
|
||||
"""
|
||||
if not immediate:
|
||||
shared = _shared_read_sessions.get()
|
||||
if shared is not None:
|
||||
key = id(engine)
|
||||
session = shared.get(key)
|
||||
if session is None:
|
||||
session = factory()
|
||||
# Register before the PRAGMAs: those executes force the pool
|
||||
# checkout, so if one raises the scope must already track the
|
||||
# session to close it (otherwise the connection would leak).
|
||||
shared[key] = session
|
||||
if is_sqlite:
|
||||
session.execute(text("PRAGMA foreign_keys = ON"))
|
||||
session.execute(text("PRAGMA busy_timeout = 20000")) # 20s
|
||||
yield session
|
||||
return
|
||||
with factory() as session:
|
||||
try:
|
||||
if is_sqlite:
|
||||
|
||||
@@ -103,9 +103,15 @@ class Conversation:
|
||||
(alongside the runner-binding primitive of the Alpha
|
||||
runner-state design). Both paths validate the value against
|
||||
the supported set; invalid values fail with ``invalid_input``.
|
||||
:param model_override: Per-session LLM model override,
|
||||
e.g. ``"claude-opus-4-7"``. ``None`` means use the agent
|
||||
default from the spec's ``llm.model``. Mutable via
|
||||
:param reported_model: The model the harness last REPORTED the
|
||||
session is actually on, verbatim in the harness's own
|
||||
spelling, e.g. ``"claude-opus-4-8[1m]"``. Written only by
|
||||
harness reports (``external_model_change``); never by user
|
||||
picks. The only model value UI surfaces display. ``None``
|
||||
means no report has arrived yet.
|
||||
:param model_override: Per-session LLM model override — the user's
|
||||
REQUEST, e.g. ``"claude-opus-4-7"``. ``None`` means use the
|
||||
agent default from the spec's ``llm.model``. Mutable via
|
||||
``PATCH /v1/sessions/{id}`` and the REPL's ``/model``
|
||||
command. Mirrors the persistence shape of
|
||||
``reasoning_effort`` so the web UI and the TUI stay
|
||||
@@ -221,10 +227,12 @@ class Conversation:
|
||||
session_usage: dict[str, Any] = field(default_factory=dict)
|
||||
reasoning_effort: str | None = None
|
||||
model_override: str | None = None
|
||||
reported_model: str | None = None
|
||||
cost_control_mode_override: str | None = None
|
||||
subagent_routing_override: str | None = None
|
||||
harness_override: str | None = None
|
||||
sub_agent_name: str | None = None
|
||||
task_summary: str | None = None
|
||||
external_session_id: str | None = None
|
||||
terminal_launch_args: list[str] | None = None
|
||||
workspace: str | None = None
|
||||
|
||||
@@ -54,6 +54,10 @@ class ErrorCode:
|
||||
rather than 400 (the request is valid against a configured
|
||||
host) or 503 (retrying cannot succeed without user action —
|
||||
running ``omnigent setup`` on the host machine).
|
||||
:cvar WORKSPACE_MISSING: The session's bound workspace no longer
|
||||
exists on the selected host (HTTP 410). Retrying cannot recreate
|
||||
deleted workspace state; the user must start a session in a valid
|
||||
workspace.
|
||||
"""
|
||||
|
||||
UNAUTHORIZED = "unauthorized"
|
||||
@@ -70,6 +74,7 @@ class ErrorCode:
|
||||
# Keep the string equal to frames.HARNESS_NOT_CONFIGURED_ERROR_CODE —
|
||||
# the host's wire error code passes through as the API error code.
|
||||
HARNESS_NOT_CONFIGURED = "harness_not_configured"
|
||||
WORKSPACE_MISSING = "workspace_missing"
|
||||
|
||||
|
||||
# Single source of truth for error code → HTTP status.
|
||||
@@ -99,6 +104,7 @@ _CODE_TO_HTTP_STATUS: dict[str, int] = {
|
||||
# can't satisfy it until the user runs `omnigent setup` there —
|
||||
# neither a 400 (input is fine) nor a 503 (a retry won't help).
|
||||
ErrorCode.HARNESS_NOT_CONFIGURED: 412,
|
||||
ErrorCode.WORKSPACE_MISSING: 410,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -326,10 +326,13 @@ async def _prepare_goose_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Goose session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Goose session...")
|
||||
session_id = await _create_goose_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_goose_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Goose session...")
|
||||
@@ -372,7 +375,8 @@ async def _prepare_goose_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user