diff --git a/.github/actions/ci-scope/action.yml b/.github/actions/ci-scope/action.yml
new file mode 100644
index 000000000..cfa3b9eb9
--- /dev/null
+++ b/.github/actions/ci-scope/action.yml
@@ -0,0 +1,38 @@
+name: Classify CI changes
+description: Check the full PR or push diff; missing history runs all checks.
+outputs:
+ runtime_changed:
+ description: Whether runtime tests and Python packaging are needed.
+ value: ${{ steps.scope.outputs.runtime_changed }}
+ desktop_changed:
+ description: Whether Desktop or bundled runtime inputs changed.
+ value: ${{ steps.scope.outputs.desktop_changed }}
+runs:
+ using: composite
+ steps:
+ - id: scope
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: |
+ run_all() {
+ echo "runtime_changed=true" >> "$GITHUB_OUTPUT"
+ echo "desktop_changed=true" >> "$GITHUB_OUTPUT"
+ }
+ if [[ "$EVENT_NAME" != "pull_request" && "$EVENT_NAME" != "push" ]] ||
+ [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]] ||
+ ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null ||
+ ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
+ run_all
+ exit 0
+ fi
+ if [[ "$EVENT_NAME" == "pull_request" ]]; then
+ if ! BASE_SHA="$(git merge-base "$BASE_SHA" "$HEAD_SHA")"; then
+ run_all
+ exit 0
+ fi
+ fi
+ git diff --no-renames --name-only -z "$BASE_SHA" "$HEAD_SHA" \
+ | python scripts/ci_scope.py >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml
index aa96cf4d5..64984735d 100644
--- a/.github/workflows/desktop-ci.yml
+++ b/.github/workflows/desktop-ci.yml
@@ -12,7 +12,7 @@ permissions:
concurrency:
group: desktop-ci-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
quality:
@@ -28,37 +28,9 @@ jobs:
with:
fetch-depth: 0
- - name: Detect desktop-impacting changes
+ - name: Classify changes
id: scope
- env:
- EVENT_NAME: ${{ github.event_name }}
- BEFORE_SHA: ${{ github.event.before }}
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
- run: |
- if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
- echo "desktop_changed=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- if [[ "$EVENT_NAME" == "pull_request" ]]; then
- compare_from="$BASE_SHA"
- else
- compare_from="$BEFORE_SHA"
- fi
-
- if [[ -z "$compare_from" || "$compare_from" =~ ^0+$ ]]; then
- echo "desktop_changed=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- if ! git cat-file -e "${compare_from}^{commit}" 2>/dev/null; then
- echo "desktop_changed=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- git diff --no-renames --name-only -z "$compare_from" "$HEAD_SHA" \
- | python3 scripts/desktop_ci_scope.py >> "$GITHUB_OUTPUT"
+ uses: ./.github/actions/ci-scope
- name: Skip unaffected Desktop checks
if: steps.scope.outputs.desktop_changed != 'true'
@@ -128,6 +100,12 @@ jobs:
with:
components: clippy, rustfmt
+ - name: Cache Rust dependencies
+ if: steps.scope.outputs.desktop_changed == 'true'
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: desktop/src-tauri
+
- name: Install frontend dependencies
if: steps.scope.outputs.desktop_changed == 'true'
working-directory: desktop
@@ -154,8 +132,8 @@ jobs:
working-directory: desktop/src-tauri
run: |
cargo fmt --check
- cargo clippy --all-targets -- -D warnings
- cargo test --all-targets
+ cargo clippy --locked --all-targets -- -D warnings
+ cargo test --locked --all-targets
bundle:
name: Bundle ${{ matrix.name }}
@@ -254,6 +232,11 @@ jobs:
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
+ - name: Cache Rust dependencies
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: desktop/src-tauri
+
- name: Install frontend dependencies
working-directory: desktop
run: npm ci
diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml
index ce9be58db..5177b4d17 100644
--- a/.github/workflows/linting.yaml
+++ b/.github/workflows/linting.yaml
@@ -8,9 +8,16 @@ on:
branches:
- main
+permissions:
+ contents: read
+
+concurrency:
+ group: lint-ci-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
jobs:
lint-and-format:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
@@ -18,14 +25,20 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
+ id: python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- - name: Install dependencies
+ - name: Cache pre-commit environments
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/pre-commit
+ key: pre-commit-${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml', 'scripts/ci/requirements.lock') }}
+
+ - name: Install pre-commit
run: |
- python -m pip install --upgrade pip
- pip install pre-commit
+ python -m pip install -c scripts/ci/requirements.lock pre-commit
- name: Run pre-commit
run: pre-commit run --all-files --show-diff-on-failure
diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml
index 3a3431211..9ef2a84f0 100644
--- a/.github/workflows/pypi-publish.yml
+++ b/.github/workflows/pypi-publish.yml
@@ -31,6 +31,19 @@ jobs:
python-version: "3.12"
cache: pip
+ - name: Set up Node for packaged web assets
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: desktop/package-lock.json
+
+ - name: Build packaged browser client
+ working-directory: desktop
+ run: |
+ npm ci
+ npm run build:web
+
- name: Build release distributions
run: |
python -m pip install --upgrade pip build packaging twine
diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml
index e06960d8c..080a51d78 100644
--- a/.github/workflows/python-ci.yml
+++ b/.github/workflows/python-ci.yml
@@ -9,9 +9,13 @@ on:
permissions:
contents: read
+concurrency:
+ group: python-ci-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
jobs:
test:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
@@ -21,22 +25,40 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Classify changes
+ id: scope
+ uses: ./.github/actions/ci-scope
- name: Set up Python
+ if: steps.scope.outputs.runtime_changed == 'true'
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
+ cache-dependency-path: scripts/ci/requirements.lock
- name: Install package and test tools
+ if: steps.scope.outputs.runtime_changed == 'true'
run: |
- python -m pip install --upgrade pip
- python -m pip install -e ".[test]"
+ python -m pip install -r scripts/ci/requirements.lock
+ python -m pip install --no-deps --no-build-isolation -e ".[test]"
- name: Run tests
+ if: steps.scope.outputs.runtime_changed == 'true'
run: |
python -m pip check
- python -m pytest -q
+ python -m pytest -q --durations=10 --junitxml=test-results/python.xml
+
+ - name: Upload test results
+ if: always() && steps.scope.outputs.runtime_changed == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: python-${{ matrix.python-version }}-results
+ path: test-results/
+ retention-days: 7
windows-lifecycle:
name: Windows lifecycle locks and platform isolation
@@ -46,21 +68,33 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Classify changes
+ id: scope
+ uses: ./.github/actions/ci-scope
- name: Set up Python
+ if: steps.scope.outputs.runtime_changed == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
+ cache-dependency-path: scripts/ci/requirements.lock
- name: Install package and test tools
+ if: steps.scope.outputs.runtime_changed == 'true'
+ shell: bash
run: |
- python -m pip install --upgrade pip
- python -m pip install -e ".[test]"
+ python -m pip install -r scripts/ci/requirements.lock
+ python -m pip install --no-deps --no-build-isolation -e ".[test]"
+ python -m pip check
- name: Verify shared leases, startup recovery, and scheduler leadership
+ if: steps.scope.outputs.runtime_changed == 'true'
run: >-
- python -m pytest -q
+ python -m pytest -q --durations=10 --junitxml=test-results/windows-lifecycle.xml
tests/application/test_application_lease.py
tests/application/test_automation_scheduler_leadership.py
tests/application/test_session_deletion_service.py
@@ -70,36 +104,77 @@ jobs:
# backend) that the ubuntu job can only skip. This is the sole place
# they actually execute.
- name: Verify Windows ACLs and Job Object sandbox
+ if: steps.scope.outputs.runtime_changed == 'true'
run: >-
- python -m pytest -q
+ python -m pytest -q --durations=10 --junitxml=test-results/windows-platform.xml
tests/test_private_storage_windows.py
tests/test_harness_sandbox.py
tests/test_exec_sandbox_wiring.py
+ tests/app_server/test_windows_task.py
+ tests/app_server/test_state_backup.py
+ tests/test_provider_oauth.py
+ tests/test_provider_protocols.py
+ tests/app_server/test_service.py
+ tests/app_server/test_service_discovery.py
+
+ - name: Upload test results
+ if: always() && steps.scope.outputs.runtime_changed == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-lifecycle-results
+ path: test-results/
+ retention-days: 7
package:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Classify changes
+ id: scope
+ uses: ./.github/actions/ci-scope
- name: Set up Python
+ if: steps.scope.outputs.runtime_changed == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
+ cache-dependency-path: scripts/ci/requirements.lock
+
+ - name: Set up Node for packaged web assets
+ if: steps.scope.outputs.runtime_changed == 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: desktop/package-lock.json
+
+ - name: Build packaged browser client
+ if: steps.scope.outputs.runtime_changed == 'true'
+ working-directory: desktop
+ run: |
+ npm ci
+ npm run build:web
- name: Build distributions
+ if: steps.scope.outputs.runtime_changed == 'true'
run: |
- python -m pip install --upgrade pip build packaging twine
- python -m build
+ python -m pip install -r scripts/ci/requirements.lock
+ python -m build --no-isolation
python -m twine check dist/*
- name: Verify distribution metadata and installed runtime
+ if: steps.scope.outputs.runtime_changed == 'true'
run: python scripts/verify_python_distribution.py --dist-dir dist
- name: Upload distributions
+ if: steps.scope.outputs.runtime_changed == 'true'
uses: actions/upload-artifact@v4
with:
name: python-distributions
diff --git a/.github/workflows/security-ci.yml b/.github/workflows/security-ci.yml
index 1aa6fb1a3..f67ff8ac0 100644
--- a/.github/workflows/security-ci.yml
+++ b/.github/workflows/security-ci.yml
@@ -14,7 +14,7 @@ permissions:
concurrency:
group: security-ci-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
secret-scan:
@@ -46,8 +46,15 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Classify changes
+ id: scope
+ uses: ./.github/actions/ci-scope
- name: Set up Node
+ if: steps.scope.outputs.runtime_changed == 'true'
uses: actions/setup-node@v4
with:
node-version: "22"
@@ -55,6 +62,7 @@ jobs:
cache-dependency-path: desktop/package-lock.json
- name: Set up Python
+ if: steps.scope.outputs.runtime_changed == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -64,9 +72,18 @@ jobs:
desktop/sidecar-requirements.lock
- name: Set up Rust
+ if: steps.scope.outputs.runtime_changed == 'true'
uses: dtolnay/rust-toolchain@stable
+ - name: Cache Rust audit tools
+ if: steps.scope.outputs.runtime_changed == 'true'
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: desktop/src-tauri
+ key: audit-0.22.2
+
- name: Audit Node dependencies
+ if: steps.scope.outputs.runtime_changed == 'true'
working-directory: desktop
run: |
npm ci
@@ -75,6 +92,7 @@ jobs:
npm sbom --sbom-format cyclonedx > build/security/node-sbom.json
- name: Audit Python package dependencies
+ if: steps.scope.outputs.runtime_changed == 'true'
run: |
python -m pip install --upgrade pip pip-audit==2.10.1
mkdir -p desktop/build/security
@@ -87,7 +105,14 @@ jobs:
--output desktop/build/security/python-sbom.json
rm -rf desktop/build/python-audit-env
+ - name: Audit pinned CI dependencies
+ if: steps.scope.outputs.runtime_changed == 'true'
+ run: >-
+ python -m pip_audit --requirement scripts/ci/requirements.lock
+ --no-deps --disable-pip
+
- name: Audit locked App Server environment
+ if: steps.scope.outputs.runtime_changed == 'true'
working-directory: desktop
run: |
npm run setup:sidecar
@@ -96,15 +121,18 @@ jobs:
--output build/security/sidecar-sbom.json
- name: Audit Rust dependencies
+ if: steps.scope.outputs.runtime_changed == 'true'
run: |
cargo install cargo-audit --version 0.22.2 --locked
cargo audit --file desktop/src-tauri/Cargo.lock
- name: Audit dependency licenses
+ if: steps.scope.outputs.runtime_changed == 'true'
working-directory: desktop
run: npm run audit:licenses
- name: Upload audit reports
+ if: steps.scope.outputs.runtime_changed == 'true'
uses: actions/upload-artifact@v4
with:
name: dependency-security-reports
diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml
new file mode 100644
index 000000000..9e32122de
--- /dev/null
+++ b/.github/workflows/web-ci.yml
@@ -0,0 +1,62 @@
+name: Web client acceptance
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: web-ci-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ browser:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Classify changes
+ id: scope
+ uses: ./.github/actions/ci-scope
+ - uses: actions/setup-python@v5
+ if: steps.scope.outputs.runtime_changed == 'true'
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: scripts/ci/requirements.lock
+ - uses: actions/setup-node@v4
+ if: steps.scope.outputs.runtime_changed == 'true'
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: desktop/package-lock.json
+ - name: Install test runtime
+ if: steps.scope.outputs.runtime_changed == 'true'
+ run: |
+ python -m pip install -r scripts/ci/requirements.lock
+ python -m pip install --no-deps --no-build-isolation -e ".[test]"
+ python -m pip check
+ - name: Install browser tooling
+ if: steps.scope.outputs.runtime_changed == 'true'
+ working-directory: desktop
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+ - name: Run deterministic browser acceptance
+ if: steps.scope.outputs.runtime_changed == 'true'
+ working-directory: desktop
+ env:
+ DEEPCODE_TEST_PYTHON: python
+ run: npm run test:web
+ - uses: actions/upload-artifact@v4
+ if: always() && steps.scope.outputs.runtime_changed == 'true'
+ with:
+ name: web-test-results
+ path: desktop/test-results/
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 06273589f..72bd3bf53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,8 +21,6 @@ build/
site/
desktop/src-tauri/target/
desktop/src-tauri/gen/
-desktop/src-tauri/binaries/*
-!desktop/src-tauri/binaries/.gitkeep
desktop/build/sidecar/
# Logs / Reports
@@ -84,6 +82,9 @@ memory-bank/
# project files
deepcode_lab/
uploads/
+/app_server/web_assets/
+/desktop/test-results/
+/desktop/playwright-report/
# Secrets belong in DeepCode's credential store or environment variables.
# NEVER commit real API keys or local user configuration.
diff --git a/MANIFEST.in b/MANIFEST.in
index 62c88db4c..937120d49 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -12,6 +12,7 @@ recursive-include core *.py
recursive-include core/skills/builtin *
recursive-include core/application/goal_prompts *.md
recursive-include app_server *.py
+recursive-include app_server/web_assets *
recursive-include protocol *.json
recursive-include utils *.py
recursive-include tools *.py
diff --git a/README.md b/README.md
index f4f720768..aaaa4a858 100644
--- a/README.md
+++ b/README.md
@@ -78,11 +78,10 @@
*Work with DeepCode in a visual workspace for Sessions, goals, tool activity, code changes, and verification.*
-DeepCode has one Agent runtime and two interfaces: an interactive CLI for
-terminal workflows and a Tauri Desktop workbench for visual Sessions, review,
-and settings. Both open the same local Projects, Session history, models,
-Skills, permissions, Goals, and Automations. See the
-[`Desktop source guide`](desktop/README.md) to run the application locally.
+DeepCode provides TUI, Desktop, and Web clients over one shared local service.
+They share Projects, Session history, models, Skills, permissions, Goals, and
+Automations. Use `deepcode`, `deepcode desktop`, or `deepcode web`; see
+[Quick start](#quick-start) for installation and your first task.
---
@@ -133,6 +132,14 @@ Skills, permissions, Goals, and Automations. See the
- [Automate repeatable engineering work](#automate-repeatable-engineering-work)
- [Paper2Code](#paper2code)
- [⚡ Quick start](#quick-start)
+ - [Launch commands at a glance](#launch-commands-at-a-glance)
+ - [Install the runtime](#install-the-runtime)
+ - [Configure a model](#configure-a-model)
+ - [Start the TUI](#start-the-tui)
+ - [Start Desktop](#start-desktop)
+ - [Start Web](#start-web)
+ - [Complete your first task](#complete-your-first-task)
+ - [Manage the shared background service](#manage-the-shared-background-service)
- [🧭 Using DeepCode](#using-deepcode)
- [⚙️ Headless and automation](docs/HEADLESS_AND_AUTOMATION.md)
- [🔬 Paper2Code](#paper2code-1)
@@ -151,6 +158,29 @@ Skills, permissions, Goals, and Automations. See the
## News
+**2026-09-09 · TUI, Desktop, and Web share one local background service**
+
+- **Choose your interface.** Run `deepcode` for the TUI, `deepcode desktop`
+ for the native app, or `deepcode web` to open the workbench in your browser.
+ All three connect to the same local service; Web needs no DeepCode account.
+- **Continue the same task across interfaces.** Closing a client leaves running
+ work in the background. Reconnect from another interface to follow the same
+ conversation, review tool activity, and respond to approvals. The computer
+ must remain awake, and pending approvals still need your response.
+- **Configure and verify your models.** Save cloud or local model connections
+ for use across all three interfaces. Browse available models, check a model's
+ response, and test streaming and tool calls before using a custom server.
+ See [Models and providers](docs/guide/models.md).
+- **Manage your background service and prepare for upgrades.** Use
+ `deepcode service` commands to check status, read logs, or stop after current
+ work finishes. Create a runtime snapshot before upgrading and restore it when
+ needed; keep project files in your usual version control or backup.
+ See [Upgrading DeepCode](docs/UPGRADE_AND_RESTORE.md).
+
+Start with the updated [Quick start](#quick-start) and
+[first coding task tutorial](docs/guide/getting-started.md).
+([#212](https://github.com/HKUDS/DeepCode/pull/212))
+
**2026-09-06 · DeepCode v2.2.0: Session context-window caps, compaction that leaves a memory, and a fully localized Desktop**
- **Cap a Session's context window.** `/context 64k` in the TUI or the
@@ -604,9 +634,9 @@ you finish real software engineering work more reliably.
## Core capabilities
-DeepCode provides a complete local Coding Agent workflow. CLI and Desktop are
-two ways to use the same Agent, Sessions, models, Skills, permissions, and task
-state.
+DeepCode provides a complete local Coding Agent workflow. TUI, Desktop, and Web
+use the same Agent, Sessions, models, Skills, permissions, and task state through
+the shared local service.
@@ -725,7 +755,14 @@ Automation does not launch a separate, reduced Agent. It uses the same
Sessions, models, Skills, permissions, approvals, and recovery behavior, and
keeps the history of every run.
-### Paper2Code
+### Shared background service
+
+Desktop, Web, TUI, `exec`, `loop`, and MCP task calls use the same local service.
+Starting a client starts the service when needed. Closing a client leaves its
+accepted tasks running; explicitly stop the service to shut down the runtime.
+See [Quick start](#quick-start) for installation and launch commands.
+
+## Paper2Code
Paper2Code was DeepCode's original research direction and remains its dedicated
workflow for research reproduction.
@@ -739,156 +776,188 @@ run, be inspected, and keep improving.
## Quick start
-DeepCode has two interfaces with separate installation paths. Choose one to get
-started; both use the same Agent runtime and canonical Session history.
+Use DeepCode to explore a repository, make changes, and run tests. Choose the
+terminal, desktop app, or browser—your projects and conversations are available
+in all three.
-> `uv tool install --python 3.12 deepcode-hku` installs the CLI and shared
-> Python runtime. It does **not** install the Tauri Desktop application.
+### Launch commands at a glance
-### Option A — Install the CLI
+After installation, open the interface you prefer:
-Install `uv` first if it is not already available. On Windows PowerShell:
+| Interface | Start command |
+|---|---|
+| **TUI** — work in your terminal | `deepcode` |
+| **Desktop** — use a native app | `deepcode desktop` |
+| **Web** — work in your browser | `deepcode web` |
-```powershell
-winget install --id astral-sh.uv --exact
-```
+DeepCode starts its local background service automatically. You can close an
+interface and return to your conversation later.
+
+### Install the runtime
-Open a new terminal after the first `uv` installation, then run:
+#### Published package
+
+With `uv` installed, run:
```console
uv tool install --python 3.12 deepcode-hku
deepcode init
```
-The explicit Python selection is intentional: DeepCode requires Python 3.12+
-and must not fall back to an unsupported legacy package on an older interpreter.
-If an existing uv tool environment still contains DeepCode 1.x, migrate it with
-`uv tool upgrade --python 3.12 deepcode-hku`.
+On Windows, you can install `uv` with `winget install --id astral-sh.uv --exact`,
+then open a new terminal. DeepCode requires Python 3.12 or later.
+
+To use the native app, also install DeepCode Desktop from
+[GitHub Releases](https://github.com/HKUDS/DeepCode/releases). To try changes
+that have not been released yet, install from source below.
+
+
+Install from source
+
+#### Current source checkout
+
+You will need Git, `uv`, and Node.js 22 or later. Run these commands in your terminal:
+
+```console
+git clone https://github.com/HKUDS/DeepCode.git
+cd DeepCode
+npm --prefix desktop ci
+npm --prefix desktop run build:web
+uv tool install --python 3.12 --force .
+deepcode init
+```
+
+If you already have the repository, start in its root directory and skip cloning.
+You can now run `deepcode` from any directory. For source Desktop, also install
+Rust and the platform dependencies described in the [Desktop guide](desktop/README.md).
+
+
+
+If your terminal cannot find `deepcode`, run `uv tool update-shell` and open a
+new terminal.
+
+### Configure a model
+
+Choose either the graphical setup or the terminal commands below.
-Create a model connection once. `--api-key` opens a non-echoing prompt:
+**In Desktop or Web:** open **Settings → AI providers**, select **Add provider**,
+and enter your provider's API key. Click **Save and check** to load its models.
+Under **Agent model**, choose a model and click **Save and verify model** to try
+a short request. Then choose the connection and model in your conversation's
+message box.
+
+
+
+
+
+**In your terminal:** this example connects to OpenRouter. Enter your API key
+when prompted; it will not be displayed as you type.
```console
-deepcode provider set personal-openrouter --template openrouter --label "OpenRouter · Personal" --api-key
-deepcode provider models personal-openrouter --refresh
-deepcode provider test personal-openrouter --model
+deepcode provider set my-openrouter --template openrouter --api-key
+deepcode provider models my-openrouter --refresh
```
-Enter the repository you want DeepCode to work in and start the interactive
-Agent:
+Choose a model from the list. Replace `MODEL_ID` below with its exact ID and
+send a short test request:
```console
-cd
-deepcode
+deepcode provider test my-openrouter --model MODEL_ID
```
-`deepcode init` creates minimal user configuration under `~/.deepcode/`.
-Credentials are stored separately in user-private storage and are never written
-to Session history. `pipx install deepcode-hku` and `pip install deepcode-hku`
-are also supported in an appropriate Python 3.12+ environment.
+You can use the same connection in all three interfaces. For other providers,
+local models, and tool-calling checks, see [Models and providers](docs/guide/models.md).
+
+### Start the TUI
-### Option B — Install Desktop
+Open a terminal in the project you want to work on. On your first visit, use
+`--trust` to allow DeepCode to work in that folder. With the connection configured
+above:
-Desktop release bundles are distributed separately from the Python package.
-Check [GitHub Releases](https://github.com/HKUDS/DeepCode/releases) for a signed
-installer for your platform. If no installer is attached, use the source setup
-below.
+```console
+cd /path/to/your-project
+deepcode --trust --connection my-openrouter --model MODEL_ID
+```
-#### macOS and Linux from source
+Replace the path with your project directory; on Windows, for example,
+`C:\projects\my-app`. For later visits, run `deepcode` in that directory and use
+`/model` to choose your connection and model. Use `/help` to see available commands.
-Install the platform dependencies from the
-[Tauri 2 prerequisite guide](https://v2.tauri.app/start/prerequisites/), plus
-Git, Python 3.12+, `uv`, Node.js 22+, and stable Rust. Then run:
+### Start Desktop
-```bash
-git clone https://github.com/HKUDS/DeepCode.git
-cd DeepCode
-uv venv --python 3.12
-uv pip install --python .venv/bin/python -e .
-.venv/bin/deepcode init
-cd desktop
-npm ci
-npm run setup:sidecar
-npm run build:sidecar
-cd ..
-mkdir -p ~/.local/bin
-ln -sf "$(pwd)/scripts/deepcode-desktop" ~/.local/bin/deepcode-desktop
-export PATH="$HOME/.local/bin:$PATH"
-deepcode-desktop
+```console
+deepcode desktop
```
-The final link is a one-time source launcher installation. Afterwards,
-`deepcode-desktop` starts this checkout from any directory, provided
-`~/.local/bin` is on `PATH`. Add the export to your shell profile if it is not
-already configured. The command launches Desktop; add or select the repository
-you want to work on from the Project sidebar.
-
-#### Windows from source
-
-Windows requires Microsoft Edge WebView2 and the Visual Studio 2022 Build Tools
-workload **Desktop development with C++**. Accept the UAC prompt raised by Build
-Tools:
-
-```powershell
-winget install --id Git.Git --exact
-winget install --id astral-sh.uv --exact
-winget install --id OpenJS.NodeJS.LTS --exact
-winget install --id Rustlang.Rustup --exact
-winget install --id Microsoft.VisualStudio.2022.BuildTools --exact `
- --override "--wait --passive --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
+Add your project folder, confirm that you trust it, and click **New thread**.
+Choose a model in the message box, then describe what you want to do.
+
+If you installed from source, this command opens the development app. Its first
+launch may take longer while dependencies are prepared; keep the terminal open
+while you use it. See the [Desktop guide](desktop/README.md) for platform setup
+and custom installation paths.
+
+### Start Web
+
+```console
+deepcode web
```
-Close PowerShell, open a new window, and verify the toolchains:
+DeepCode opens a browser tab. Add your project folder on this computer, confirm
+that you trust it, and create a thread. Select a model and send your first message.
+No DeepCode account is needed.
+
+To copy the access link and open it yourself, use `deepcode web --no-open`.
+Open the link within 60 seconds. If it expires or the page asks you to sign in
+again, run `deepcode web` to get a fresh link.
+
+### Complete your first task
-```powershell
-git --version
-uv --version
-node --version
-rustup default stable-msvc
-rustc --version
-cargo --version
+Start by asking DeepCode to help you understand your project:
+
+```text
+Explain how this project is organized and how to run its tests.
+Do not change any files yet.
```
-Clone, prepare, and start Desktop:
+Then ask for a small change, describing the behavior you want and how to check it:
-```powershell
-git clone https://github.com/HKUDS/DeepCode.git
-Set-Location DeepCode
-uv venv --python 3.12
-uv pip install --python .venv\Scripts\python.exe -e .
-.venv\Scripts\deepcode.exe init
-Set-Location desktop
-npm ci
-$env:DEEPCODE_PYTHON = (Resolve-Path ..\.venv\Scripts\python.exe)
-npm run setup:sidecar
-npm run build:sidecar
-npm run tauri -- dev
+```text
+Add tests for the configuration loader's handling of missing and invalid
+values. Follow the existing test style, run the relevant tests, and summarize
+the changes and results.
```
-Keep that PowerShell window open while Desktop is running. See the
-[Desktop source guide](desktop/README.md#windows-powershell) for subsequent
-launches and troubleshooting.
+Adapt the task to your repository. Follow the tool activity as DeepCode works,
+respond to any approval requests, and review the changed files and test output.
+You can send a follow-up message to adjust the task, or use the stop button
+(`/stop` in the TUI) to interrupt it.
-#### Configure the Desktop model
+Your conversation is saved automatically. In Desktop/Web, select it from the
+project's thread list. In the TUI, use `/resume` to pick a saved conversation.
+You can switch interfaces and continue the same task.
-Open **Settings → AI providers** after Desktop starts.
+For a complete exercise in a new folder, follow
+[Your first coding task](docs/guide/getting-started.md). It takes you through
+creating a Python function, testing it, and returning to the conversation later.
-
-
+### Manage the shared background service
-
Provider credentials, model discovery, and inference verification stay in one Desktop workflow.
-
+Closing an interface leaves running tasks in the background. Pending approvals
+still need your response, and the computer must remain awake for work to continue.
+
+Most days, you can just launch your preferred interface. When you need to inspect
+or stop the background service, use the command for that action:
-1. Select **Add provider**, choose the service, and enter an API key or its
- environment-variable name.
-2. Select **Save and check** to verify the credential and load the provider's
- model catalog without sending repository content.
-3. Under **Agent model**, choose an exact model ID and select **Save and verify
- model**. This final check sends only a minimal inference request.
-4. Add or open a Project, create a Session, choose the model, Thinking effort,
- and access level, then describe the task in natural language.
+| What you want to do | Command |
+|---|---|
+| Check whether DeepCode is running | `deepcode service status` |
+| Read recent logs | `deepcode service logs --lines 100` |
+| Stop after current work finishes | `deepcode service stop --drain --timeout 60` |
+| Start automatically after signing into your computer | `deepcode service install --at-login` |
-> The interface changes how the work is presented, not the Agent, policy,
-> configuration, or Session history behind it.
+For updates and backups, follow [Upgrading DeepCode](docs/UPGRADE_AND_RESTORE.md).
+If something does not work, start with [Troubleshooting](docs/guide/troubleshooting.md).
## Using DeepCode
@@ -900,10 +969,10 @@ skills and memory, and headless automation — with worked examples.
Every task lives in a durable Session attached to its original Project. Open a
Project in Desktop or start `deepcode` from its directory, then create a new
-Session or resume an existing one. The same history can move between Desktop
-and CLI without export or conversion.
+Session or resume an existing one. The same history is available in TUI,
+Desktop, and Web without export or conversion.
-| What you want to do | Desktop | Interactive CLI |
+| What you want to do | Desktop / Web | Interactive CLI |
|---|---|---|
| Start a Session | **New thread** | `/new [title]` |
| Resume local history | Select it under the Project | `/resume` |
@@ -924,9 +993,11 @@ and CLI without export or conversion.
| Rename or delete a Session | Session context menu | `/rename ` · `/delete ` |
Session history, tool activity, approvals, Goal state, and verification evidence
-remain together. A Session has one live writer at a time: Desktop and the CLI
-may hold it open together, but if one is mid-Turn the other refuses new input
-with a message naming the holder instead of corrupting shared history. Archiving hides a Session without deleting its history;
+remain together. TUI, Desktop, and Web can observe the same Session while the
+shared service serializes its execution. Normal input during a Turn steers the
+active work; explicit queueing requests a subsequent Turn. The interface reports
+how the input was accepted. Closing a client leaves accepted work running.
+Archiving hides a Session without deleting its history;
permanent deletion removes the Session records but never repository files.
### Connections and models
@@ -1247,6 +1318,9 @@ On Windows PowerShell, activate with `.\.venv\Scripts\Activate.ps1`.
### Verification
+To reproduce CI's Python environment and understand each check, see the
+[CI guide](docs/CI.md).
+
```bash
uvx pre-commit run --all-files
python -m compileall -q app_server cli core tools workflows
diff --git a/README_ZH.md b/README_ZH.md
index 78c1749ea..e24929a27 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -77,11 +77,10 @@
*在可视化工作台中使用 DeepCode,管理 Session 和目标,并查看工具活动、代码修改与验证过程。*
-DeepCode 只有一套 Agent 运行时,同时提供两种使用界面:面向终端
-工作流的交互式 CLI,以及用于 Session、审查和设置的 Tauri Desktop。
-两端打开同一份本地 Project、Session 历史、模型、Skills、权限、Goals 与
-Automations。从源码启动请参考
-[`Desktop 运行指南`](desktop/README.md)。
+DeepCode 提供 TUI、Desktop 和 Web 三种客户端,连接同一个本地共享后台,
+共用 Project、Session 历史、模型、Skills、权限、Goals 与 Automations。
+分别使用 `deepcode`、`deepcode desktop` 和 `deepcode web` 启动;安装及首次任务
+见[快速开始](#快速开始)。
---
@@ -132,6 +131,14 @@ Automations。从源码启动请参考
- [让可重复的工程工作自动化](#让可重复的工程工作自动化)
- [Paper2Code](#paper2code)
- [⚡ 快速开始](#快速开始)
+ - [三种启动入口](#三种启动入口)
+ - [安装 DeepCode](#安装-deepcode)
+ - [配置并选择模型](#配置并选择模型)
+ - [启动 TUI](#启动-tui)
+ - [启动 Desktop](#启动-desktop)
+ - [启动 Web](#启动-web)
+ - [完成并验收第一个任务](#完成并验收第一个任务)
+ - [管理共享后台与排查问题](#管理共享后台与排查问题)
- [🧭 使用 DeepCode](#使用-deepcode)
- [⚙️ Headless 与 Automation](docs/HEADLESS_AND_AUTOMATION.md#中文)
- [🔬 Paper2Code](#paper2code-1)
@@ -150,6 +157,25 @@ Automations。从源码启动请参考
## 新闻
+**2026-09-09 · TUI、Desktop 与 Web 共用一个本地后台**
+
+- **选择你习惯的界面。** 运行 `deepcode` 启动 TUI,`deepcode desktop`
+ 打开桌面应用,或用 `deepcode web` 在浏览器中打开工作台。三种界面连接
+ 同一个本地后台,Web 无需注册 DeepCode 账号。
+- **换个界面,继续同一个任务。** 关闭客户端后,正在执行的任务继续在后台
+ 运行。你可以从其他界面重新连接,查看同一段对话、工具活动并处理审批。
+ 任务继续执行需要电脑保持唤醒,遇到审批时仍需你确认。
+- **配置模型,并验证它能否正常工作。** 保存的云端或本地模型连接可在三种
+ 界面中使用。你可以查看可用模型、发送简短请求检查响应,并在使用自定义
+ 模型服务前验证流式输出和工具调用。参见[模型与服务商指南](docs/guide/models.md)。
+- **管理后台,为升级做好准备。** 使用 `deepcode service` 命令查看状态和
+ 日志,或等待当前任务完成后停止服务。升级前可以创建运行时数据快照,
+ 需要时再恢复;项目文件仍使用你原有的版本管理或备份方式。
+ 参见[升级与恢复指南](docs/UPGRADE_AND_RESTORE.md)。
+
+从更新后的[快速开始](#快速开始)和[第一个编程任务教程](docs/guide/getting-started.md)
+开始使用。([#212](https://github.com/HKUDS/DeepCode/pull/212))
+
**2026-09-06 · DeepCode v2.2.0:Session 上下文窗口上限、会留下记忆的压缩,以及完整本地化的 Desktop**
- **给 Session 的上下文窗口设上限。** TUI 里的 `/context 64k` 或 Desktop 模型
@@ -497,7 +523,7 @@ DeepCode 的目标不是让 Agent 显得更忙,而是帮助你更可靠地完
## 核心能力
-DeepCode 提供完整的本地 Coding Agent 工作流。CLI 和 Desktop 只是两种使用方式,它们共享同一套 Agent、Session、模型、Skills、权限和任务状态。
+DeepCode 提供完整的本地 Coding Agent 工作流。TUI、Desktop 和 Web 通过共享后台,使用同一套 Agent、Session、模型、Skills、权限和任务状态。
@@ -584,150 +610,171 @@ Paper2Code 是 DeepCode 最初的研究方向,也是当前产品中专门面
## 快速开始
-DeepCode 提供两种界面,并且对应两条独立安装路径。任选一种即可开始;两端
-使用相同的 Agent 运行时和规范 Session 历史。
+你可以用 DeepCode 阅读项目代码、开发功能、修复问题和运行测试。选择习惯的终端、
+桌面应用或浏览器即可,项目和对话可以在三端继续使用。
-> `uv tool install --python 3.12 deepcode-hku` 安装的是 CLI 和共享 Python
-> 运行时,**不会**安装 Tauri Desktop 应用。
+### 三种启动入口
-### 方案 A:安装 CLI
+安装后,选择一种方式打开 DeepCode:
-如果尚未安装 `uv`,请先安装。Windows PowerShell 使用:
+| 界面 | 启动命令 |
+|---|---|
+| **TUI**:在终端中工作 | `deepcode` |
+| **Desktop**:使用桌面应用 | `deepcode desktop` |
+| **Web**:在浏览器中工作 | `deepcode web` |
-```powershell
-winget install --id astral-sh.uv --exact
-```
+DeepCode 会自动启动本地后台。你可以关闭界面,下次再回来继续同一段对话。
+
+### 安装 DeepCode
-首次安装 `uv` 后重新打开终端,再执行:
+#### 安装已发布版本
+
+准备好 `uv` 后,在终端运行:
```console
uv tool install --python 3.12 deepcode-hku
deepcode init
```
-这里显式选择 Python 是有意的:DeepCode 要求 Python 3.12+,不能在旧解释器上
-回退安装已经不受支持的历史版本。
-如果现有 uv tool 环境仍安装着 DeepCode 1.x,可执行
-`uv tool upgrade --python 3.12 deepcode-hku` 完成迁移。
+Windows 用户可以先运行 `winget install --id astral-sh.uv --exact` 安装 `uv`,
+再重新打开终端。DeepCode 需要 Python 3.12 或更高版本。
+
+如果想使用桌面应用,还需要从 [GitHub Releases](https://github.com/HKUDS/DeepCode/releases)
+安装 DeepCode Desktop。想体验尚未发布的更新,可以使用下面的源码安装方式。
+
+
+从源码安装
+
+#### 安装当前源码版本
+
+准备 Git、`uv` 和 Node.js 22 或更高版本,然后运行:
+
+```console
+git clone https://github.com/HKUDS/DeepCode.git
+cd DeepCode
+npm --prefix desktop ci
+npm --prefix desktop run build:web
+uv tool install --python 3.12 --force .
+deepcode init
+```
+
+已有仓库时,从仓库根目录开始,跳过克隆步骤。安装完成后,可在任意目录运行
+`deepcode`。如果还要运行源码版 Desktop,请按照 [Desktop 指南](desktop/README.md)
+准备 Rust 和对应平台的依赖。
+
+
+
+如果终端提示找不到 `deepcode`,运行 `uv tool update-shell`,再重新打开终端。
+
+### 配置并选择模型
+
+你可以在图形界面中配置,也可以使用终端命令,任选一种即可。
-首次创建模型连接。`--api-key` 会打开不回显的安全输入:
+**在 Desktop 或 Web 中:** 打开 **Settings → AI providers**,点击 **Add provider**,
+选择模型服务并填写 API Key。点击 **Save and check** 获取模型列表;在 **Agent model**
+中选择模型,再点击 **Save and verify model** 发送一条简短请求,检查模型是否可用。
+随后在对话输入框中选择要使用的连接和模型。
+
+
+
+
+
+**在终端中:** 下面以 OpenRouter 为例。按提示输入 API Key,输入内容不会显示在终端上。
```console
-deepcode provider set personal-openrouter --template openrouter --label "OpenRouter · Personal" --api-key
-deepcode provider models personal-openrouter --refresh
-deepcode provider test personal-openrouter --model
+deepcode provider set my-openrouter --template openrouter --api-key
+deepcode provider models my-openrouter --refresh
```
-进入希望 DeepCode 操作的仓库,然后启动交互式 Agent:
+从列表中选择一个模型,将下文中的 `MODEL_ID` 替换为它的完整 ID,再测试连接:
```console
-cd <你的项目>
-deepcode
+deepcode provider test my-openrouter --model MODEL_ID
```
-`deepcode init` 会在 `~/.deepcode/` 下创建最小用户配置。凭证单独保存在
-用户私有存储中,不会进入 Session 历史。也可以在合适的 Python 3.12+
-环境中使用 `pipx install deepcode-hku` 或 `pip install deepcode-hku`。
+配置好的连接可以在三端使用。其他模型服务、本地模型和工具调用检查,见
+[模型配置指南](docs/guide/models.md)。
-### 方案 B:安装 Desktop
+### 启动 TUI
-Desktop 安装包与 Python 包分开发布。先检查
-[GitHub Releases](https://github.com/HKUDS/DeepCode/releases) 是否提供当前
-平台的签名安装包;如果没有,请使用下面的源码安装流程。
+进入希望 DeepCode 帮你处理的项目目录。第一次使用该目录时,加上 `--trust`,
+表示你信任这个项目并允许 DeepCode 在其中工作。使用上面配置的连接启动:
-#### macOS 与 Linux 源码安装
+```console
+cd /path/to/your-project
+deepcode --trust --connection my-openrouter --model MODEL_ID
+```
-请先根据 [Tauri 2 前置依赖指南](https://v2.tauri.app/start/prerequisites/)
-安装平台依赖,并准备 Git、Python 3.12+、`uv`、Node.js 22+ 和稳定版 Rust。
-然后执行:
+将路径替换为你的项目目录;Windows 路径例如 `C:\projects\my-app`。以后回到这个
+目录,运行 `deepcode` 即可打开 TUI。输入 `/model` 选择连接和模型,输入 `/help`
+查看可用命令。
-```bash
-git clone https://github.com/HKUDS/DeepCode.git
-cd DeepCode
-uv venv --python 3.12
-uv pip install --python .venv/bin/python -e .
-.venv/bin/deepcode init
-cd desktop
-npm ci
-npm run setup:sidecar
-npm run build:sidecar
-cd ..
-mkdir -p ~/.local/bin
-ln -sf "$(pwd)/scripts/deepcode-desktop" ~/.local/bin/deepcode-desktop
-export PATH="$HOME/.local/bin:$PATH"
-deepcode-desktop
+### 启动 Desktop
+
+```console
+deepcode desktop
```
-以上步骤会完成一次性的源码启动器安装。以后只要 `~/.local/bin` 已加入
-`PATH`,就可以从任意目录运行 `deepcode-desktop` 启动这个源码版本。如果
-Shell 尚未配置该路径,请把上述 export 写入 Shell profile。命令只负责启动
-Desktop;需要操作的仓库仍应在 Project 侧边栏中添加或选择。
+添加你的项目文件夹,确认信任后,点击 **New thread** 开始对话。在输入框中选择
+模型,然后描述你希望完成的任务。
-#### Windows 源码安装
+如果从源码安装,该命令会打开开发版应用。首次启动需要准备依赖,可能花费较长时间;
+使用期间请保持启动终端开启。平台依赖和自定义安装位置见 [Desktop 指南](desktop/README.md)。
-Windows 必须安装 Microsoft Edge WebView2,以及 Visual Studio 2022 Build
-Tools 的 **Desktop development with C++** 工作负载。Build Tools 弹出 UAC
-提示时请选择“是”:
+### 启动 Web
-```powershell
-winget install --id Git.Git --exact
-winget install --id astral-sh.uv --exact
-winget install --id OpenJS.NodeJS.LTS --exact
-winget install --id Rustlang.Rustup --exact
-winget install --id Microsoft.VisualStudio.2022.BuildTools --exact `
- --override "--wait --passive --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
+```console
+deepcode web
```
-关闭 PowerShell,重新打开一个窗口并验证工具链:
+DeepCode 会自动打开浏览器页面。添加这台电脑上的项目目录,确认信任,创建对话,
+选择模型后即可开始使用,无需 DeepCode 账号。
+
+如果希望自己复制链接打开,运行 `deepcode web --no-open`。请在 60 秒内打开生成的
+访问链接;链接过期或页面提示重新授权时,再运行一次 `deepcode web` 即可。
+
+### 完成并验收第一个任务
+
+进入项目后,可以先让 DeepCode 帮你了解代码:
-```powershell
-git --version
-uv --version
-node --version
-rustup default stable-msvc
-rustc --version
-cargo --version
+```text
+介绍这个项目的目录结构、主要模块,以及应该如何运行测试。先不要修改文件。
```
-克隆、准备并启动 Desktop:
+了解项目后,再提出一个具体的小任务,说明期望的行为和检查方法。例如:
-```powershell
-git clone https://github.com/HKUDS/DeepCode.git
-Set-Location DeepCode
-uv venv --python 3.12
-uv pip install --python .venv\Scripts\python.exe -e .
-.venv\Scripts\deepcode.exe init
-Set-Location desktop
-npm ci
-$env:DEEPCODE_PYTHON = (Resolve-Path ..\.venv\Scripts\python.exe)
-npm run setup:sidecar
-npm run build:sidecar
-npm run tauri -- dev
+```text
+为配置加载模块补充缺少配置项、配置值不合法时的测试。沿用项目现有的测试风格,
+运行相关测试,并说明修改了哪些文件、测试结果如何。
```
-Desktop 运行期间请保持这个 PowerShell 窗口开启。后续启动和故障排查请参考
-[Desktop 源码运行指南](desktop/README.md#windows-powershell)。
+请按你的项目调整任务内容。DeepCode 工作时,你可以查看工具调用、处理审批,
+并检查文件修改和测试输出。需要调整方向时,直接发送补充消息;需要中断时,
+点击停止按钮,或在 TUI 输入 `/stop`。
-#### 配置 Desktop 模型
+对话会自动保存。在 Desktop/Web 中,从项目的对话列表重新打开;在 TUI 中,
+输入 `/resume` 选择历史对话。换一个界面,也可以接着做同一个任务。
-Desktop 启动后,打开 **Settings → AI providers**。
+如果想从空目录开始练习,请阅读[第一个编程任务](docs/guide/getting-started.md):
+跟着创建一个 Python 函数、运行测试,再学习如何恢复对话。
+更多用法见[使用指南目录](docs/guide/README.md)。
-
-
+### 管理共享后台与排查问题
-
在同一个 Desktop 流程中完成凭证保存、模型发现和真实推理验证。
-
+关闭界面后,已经开始的任务会继续在后台运行;遇到待审批操作时,仍需你回来处理。
+执行期间请保持电脑运行,避免休眠。
-1. 点击 **Add provider**,选择模型服务,并输入 API Key 或保存它的环境变量名。
-2. 点击 **Save and check**,检查凭证并读取 Provider 的模型目录;这一阶段不会
- 发送项目内容。
-3. 在 **Agent model** 中选择准确的模型 ID,然后点击 **Save and verify
- model**;最后一步只会发送一次极小的真实推理请求。
-4. 添加或打开 Project,创建 Session,选择模型、Thinking 档位和权限,然后
- 用自然语言描述任务。
+日常直接打开你喜欢的界面即可。需要检查或停止后台时,按需使用以下命令:
-> 使用界面只改变工作的呈现方式,不改变背后的 Agent、策略、配置和 Session
-> 历史。
+| 你想做什么 | 命令 |
+|---|---|
+| 检查 DeepCode 是否正在运行 | `deepcode service status` |
+| 查看最近的运行日志 | `deepcode service logs --lines 100` |
+| 等当前工作结束后停止后台 | `deepcode service stop --drain --timeout 60` |
+| 登录电脑后自动启动后台 | `deepcode service install --at-login` |
+
+更新版本和备份数据时,参考[升级与恢复指南](docs/UPGRADE_AND_RESTORE.md)。
+遇到启动、模型连接或页面断线问题,先查看[故障排查](docs/guide/troubleshooting.md)。
## 使用 DeepCode
@@ -735,9 +782,11 @@ Desktop 启动后,打开 **Settings → AI providers**。
每项工作都保存在与原始 Project 关联的持久 Session 中。在 Desktop 打开
Project,或者从项目目录启动 `deepcode`,然后创建新 Session 或恢复历史。
-同一份历史可以直接在 Desktop 与 CLI 之间继续,无需导出或转换。
+同一份历史可以在 TUI、Desktop 和 Web 中继续,无需导出或转换。共享后台统一执行
+任务;执行中发送的普通消息用于引导当前 Turn,显式排队用于后续 Turn。
+多个客户端可同时查看同一 Session,并处理其中的审批。
-| 需要完成的操作 | Desktop | 交互式 CLI |
+| 需要完成的操作 | Desktop / Web | 交互式 CLI |
|---|---|---|
| 创建 Session | **New thread** | `/new [标题]` |
| 恢复当前项目历史 | 在 Project 下选择 Session | `/resume` |
@@ -1033,6 +1082,8 @@ Windows PowerShell 请使用 `.\.venv\Scripts\Activate.ps1` 激活环境。
### 验证
+复现 CI 的 Python 环境、了解各项检查及排查失败,请参考 [CI 指南](docs/CI.md)。
+
```bash
uvx pre-commit run --all-files
python -m compileall -q app_server cli core tools workflows
diff --git a/app_server/__init__.py b/app_server/__init__.py
index 591c999f5..3a4ec6328 100644
--- a/app_server/__init__.py
+++ b/app_server/__init__.py
@@ -1,5 +1 @@
-"""DeepCode local stdio App Server."""
-
-from app_server.server import AppServer
-
-__all__ = ["AppServer"]
+"""DeepCode local service and client transports."""
diff --git a/app_server/__main__.py b/app_server/__main__.py
index b932a213f..025df5209 100644
--- a/app_server/__main__.py
+++ b/app_server/__main__.py
@@ -1,4 +1,4 @@
-"""Run the local App Server over stdin/stdout."""
+"""Attach stdin/stdout to the local DeepCode service."""
from __future__ import annotations
@@ -10,7 +10,6 @@
import time
from pathlib import Path
-
_PROCESS_STARTED = time.perf_counter()
@@ -22,14 +21,29 @@ def _trace_startup(stage: str) -> None:
_trace_startup("entrypoint")
-from app_server.server import AppServer
-from core.application.application import DeepCodeApplication
-
-_trace_startup("imports-ready")
-
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="DeepCode stdio App Server")
+ parser.add_argument(
+ "--service",
+ action="store_true",
+ help="manage the background service (accepts service subcommands)",
+ )
+ parser.add_argument(
+ "--serve",
+ action="store_true",
+ help="run the loopback Web service (accepts serve options)",
+ )
+ parser.add_argument(
+ "--web",
+ action="store_true",
+ help="start/connect to the service and open its browser client (accepts web options)",
+ )
+ parser.add_argument(
+ "--managed-config",
+ type=Path,
+ help="read a private supervisor launch configuration",
+ )
parser.add_argument(
"--database",
type=Path,
@@ -44,8 +58,25 @@ def build_parser() -> argparse.ArgumentParser:
return parser
-def main(argv: list[str] | None = None) -> int:
- args = build_parser().parse_args(argv)
+def main(argv: list[str] | None = None, *, shared_service: bool = True) -> int:
+ arguments = list(sys.argv[1:] if argv is None else argv)
+ if "--service" in arguments:
+ from cli.service_cli import run as service_cli_main
+
+ return service_cli_main([value for value in arguments if value != "--service"])
+ if "--serve" in arguments:
+ from app_server.service import main as serve_main
+
+ return serve_main([value for value in arguments if value != "--serve"])
+ if "--web" in arguments:
+ from cli.web_cli import run as web_main
+
+ return web_main([value for value in arguments if value != "--web"])
+ args = build_parser().parse_args(arguments)
+ if args.managed_config is not None:
+ from app_server.managed_entry import run as managed_main
+
+ return managed_main(args.managed_config)
if args.verify_runtime:
from app_server.runtime_probe import verify_runtime
@@ -57,6 +88,19 @@ def main(argv: list[str] | None = None) -> int:
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
source, protocol_sink = isolate_protocol_streams()
+ if shared_service:
+ from app_server.service_state import ServiceFiles
+ from app_server.stdio_relay import serve_relay
+ from core.persistence.database import default_database_path
+
+ return serve_relay(
+ ServiceFiles(args.database or default_database_path()),
+ source,
+ protocol_sink,
+ )
+ from app_server.server import AppServer
+ from core.application.application import DeepCodeApplication
+
_trace_startup("opening-application")
application = DeepCodeApplication.open(
args.database,
diff --git a/app_server/blocking_client.py b/app_server/blocking_client.py
new file mode 100644
index 000000000..f49a85f11
--- /dev/null
+++ b/app_server/blocking_client.py
@@ -0,0 +1,175 @@
+"""Synchronous native RPC bridge for existing CLI command handlers."""
+
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import json
+import threading
+import time
+
+from app_server.errors import RpcError
+from app_server.native_client import NativeRpcClient
+from app_server.service_client import ServiceOperationError, ServiceUnavailable
+from app_server.service_state import ServiceFiles
+from core.application.errors import (
+ ApplicationError,
+ ExpectedTurnMismatchError,
+ NoActiveTurnError,
+ TurnAlreadyRunningError,
+ TurnNotSteerableError,
+)
+
+
+class RemoteApplicationError(ApplicationError):
+ def __init__(self, error: RpcError):
+ super().__init__(str(error), details=error.data.get("details"))
+ self.code = error.stable_code
+ self.retryable = error.data.get("retryable", False)
+
+
+class BlockingServiceClient:
+ """One owned I/O thread; close releases transport, never service work."""
+
+ def __init__(
+ self, files: ServiceFiles, *, surface: str = "cli", start: bool = True
+ ):
+ self.files = files
+ self.surface = surface
+ self._connection_lock = threading.RLock()
+ self.generation = 0
+ self.loop = asyncio.new_event_loop()
+ self.thread = threading.Thread(
+ target=self.loop.run_forever, name="deepcode-cli-rpc", daemon=True
+ )
+ self.thread.start()
+ self.client = NativeRpcClient(files)
+ self._closed = False
+ try:
+ self.info = self.run(
+ self.client.connect(
+ {
+ "protocolVersion": "1.0",
+ "clientInfo": {
+ "name": "deepcode-cli",
+ "version": "1",
+ "surface": surface,
+ },
+ },
+ start=start,
+ )
+ )
+ except BaseException:
+ self.close()
+ raise
+
+ def run(self, coroutine, *, timeout: float = 40):
+ if self._closed:
+ coroutine.close()
+ raise RuntimeError("Service client is closed")
+ future = asyncio.run_coroutine_threadsafe(coroutine, self.loop)
+ try:
+ return future.result(timeout=timeout)
+ except concurrent.futures.TimeoutError:
+ future.cancel()
+ raise RuntimeError(
+ "Service request timed out; inspect its state before retrying"
+ ) from None
+ except (ServiceUnavailable, ServiceOperationError) as exc:
+ raise RemoteApplicationError(
+ RpcError(-32000, str(exc), stable_code="SERVICE_UNAVAILABLE")
+ ) from exc
+ except RpcError as exc:
+ details = exc.data.get("details") or {}
+ if exc.stable_code == ExpectedTurnMismatchError.code:
+ raise ExpectedTurnMismatchError(
+ details.get("expectedTurnId", "unknown"),
+ details.get("actualTurnId"),
+ ) from exc
+ for error_type in (
+ NoActiveTurnError,
+ TurnAlreadyRunningError,
+ TurnNotSteerableError,
+ ):
+ if exc.stable_code == error_type.code:
+ raise error_type(str(exc), details=details) from exc
+ raise RemoteApplicationError(exc) from exc
+
+ def reconnect(self, previous=None):
+ with self._connection_lock:
+ if self._closed:
+ raise RuntimeError("Service client is closed")
+ if previous is not None and self.client is not previous:
+ return
+ old = self.client
+ self.run(old.close())
+ candidate = NativeRpcClient(self.files)
+ info = self.run(
+ candidate.connect(
+ {
+ "protocolVersion": "1.0",
+ "clientInfo": {
+ "name": "deepcode-cli",
+ "version": "1",
+ "surface": self.surface,
+ },
+ }
+ )
+ )
+ self.client = candidate
+ self.info = info
+ self.generation += 1
+
+ def call(self, method: str, params: dict):
+ original = json.loads(json.dumps(params))
+ policy = self.info.get("capabilities", {}).get("requestRetry", {})
+ key = policy.get("keyedMethods", {}).get(method)
+ safe = method in policy.get("readMethods", []) or (
+ key and isinstance(original.get(key), str) and bool(original[key].strip())
+ )
+ for attempt in range(3):
+ current = self.client
+ try:
+ if current.closed.is_set() and safe:
+ self.reconnect(current)
+ latest = self.info.get("capabilities", {}).get("requestRetry", {})
+ if attempt and not (
+ method in latest.get("readMethods", [])
+ or (key and latest.get("keyedMethods", {}).get(method) == key)
+ ):
+ raise RuntimeError(
+ "Service retry policy changed; inspect the original operation before retrying"
+ )
+ return self.run(
+ self.client.request(method, original),
+ timeout=125 if method == "provider/test" else 40,
+ )
+ except RemoteApplicationError as exc:
+ if (
+ not safe
+ or exc.code
+ not in {
+ "CONNECTION_LOST",
+ "RESULT_UNKNOWN",
+ "NOT_CONNECTED",
+ "INPUT_DELIVERY_PENDING",
+ }
+ or attempt == 2
+ ):
+ raise
+ if exc.code != "INPUT_DELIVERY_PENDING":
+ self.reconnect(current)
+ time.sleep(0.1 * (attempt + 1))
+
+ def close(self):
+ with self._connection_lock:
+ if self._closed:
+ return
+ try:
+ self.run(self.client.close())
+ finally:
+ self._closed = True
+ self.loop.call_soon_threadsafe(self.loop.stop)
+ self.thread.join(timeout=2)
+ if not self.thread.is_alive():
+ self.loop.close()
diff --git a/app_server/browser_auth.py b/app_server/browser_auth.py
new file mode 100644
index 000000000..e70389b54
--- /dev/null
+++ b/app_server/browser_auth.py
@@ -0,0 +1,72 @@
+"""Instance-local browser sessions; all access belongs to the HTTP event loop."""
+
+from __future__ import annotations
+
+import secrets
+import time
+from collections import deque
+from collections.abc import Callable
+
+from aiohttp import web
+
+
+class BrowserAuth:
+ TICKET_TTL = 60
+ SESSION_TTL = 12 * 60 * 60
+ CAPACITY = 64
+ EXCHANGES_PER_MINUTE = 60
+
+ def __init__(
+ self, instance_id: str, *, clock: Callable[[], float] = time.monotonic
+ ):
+ # Cookies are not scoped by port. Instance-specific names avoid collisions
+ # between separate local databases, and restart invalidates all sessions.
+ self.cookie_name = f"deepcode_session_{instance_id}"
+ self._clock = clock
+ self._tickets: dict[str, float] = {}
+ self._sessions: dict[str, float] = {}
+ self._attempts: deque[float] = deque()
+
+ def issue(self) -> dict[str, str | int]:
+ self._prune(self._tickets)
+ if len(self._tickets) >= self.CAPACITY:
+ raise web.HTTPTooManyRequests(text="Too many pending browser links")
+ ticket = secrets.token_urlsafe(32)
+ self._tickets[ticket] = self._clock() + self.TICKET_TTL
+ return {"ticket": ticket, "expiresIn": self.TICKET_TTL}
+
+ def exchange(self, ticket: object) -> str:
+ now = self._clock()
+ while self._attempts and self._attempts[0] <= now - 60:
+ self._attempts.popleft()
+ if len(self._attempts) >= self.EXCHANGES_PER_MINUTE:
+ raise web.HTTPTooManyRequests(text="Too many exchange attempts")
+ self._attempts.append(now)
+ self._prune(self._tickets)
+ self._prune(self._sessions)
+ if not isinstance(ticket, str) or ticket not in self._tickets:
+ raise web.HTTPUnauthorized(text="Invalid or expired browser link")
+ if len(self._sessions) >= self.CAPACITY:
+ raise web.HTTPTooManyRequests(text="Too many browser sessions")
+ del self._tickets[ticket]
+ session = secrets.token_urlsafe(32)
+ self._sessions[session] = now + self.SESSION_TTL
+ return session
+
+ def remaining(self, session: str) -> float:
+ return max(0.0, self._sessions.get(session, 0.0) - self._clock())
+
+ def require(self, request: web.Request) -> str:
+ session = request.cookies.get(self.cookie_name, "")
+ if not self.remaining(session):
+ raise web.HTTPUnauthorized(text="Browser session expired; open a new link")
+ return session
+
+ def revoke(self, session: str) -> None:
+ self._sessions.pop(session, None)
+
+ def _prune(self, entries: dict[str, float]) -> None:
+ now = self._clock()
+ for key, expiry in tuple(entries.items()):
+ if expiry <= now:
+ del entries[key]
diff --git a/app_server/config_watch.py b/app_server/config_watch.py
index 6247ddfad..ffc93a205 100644
--- a/app_server/config_watch.py
+++ b/app_server/config_watch.py
@@ -1,4 +1,4 @@
-"""Notify a connection when the user configuration file changes on disk."""
+"""Notify the RPC host when the user configuration file changes on disk."""
from __future__ import annotations
@@ -40,7 +40,11 @@ def start(self) -> None:
name="deepcode-config-watch",
daemon=True,
)
- self._thread.start()
+ try:
+ self._thread.start()
+ except BaseException:
+ self._thread = None
+ raise
def stop(self) -> None:
self._stop.set()
diff --git a/app_server/connection.py b/app_server/connection.py
index bebaa2e7d..8e6aa8467 100644
--- a/app_server/connection.py
+++ b/app_server/connection.py
@@ -29,6 +29,9 @@ def initialize(
self.initialized = True
def close(self) -> None:
+ self.initialized = False
+ self.client_name = None
+ self.client_surface = ClientSurface.APP_SERVER
if self.subscription_token is not None:
self.broker.unsubscribe(self.subscription_token)
self.subscription_token = None
diff --git a/app_server/dispatcher.py b/app_server/dispatcher.py
index fdd31e38c..93d49253a 100644
--- a/app_server/dispatcher.py
+++ b/app_server/dispatcher.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable
from typing import Any
@@ -15,6 +16,7 @@
from app_server.protocol import methods as rpc_methods
from app_server.protocol.codec import DEFAULT_MAX_MESSAGE_BYTES
from app_server.protocol.models import Request
+from app_server.protocol.retry import retry_capabilities
from core.agent_presets import METADATA_KEY as PRESET_METADATA_KEY
from core.agent_presets import list_agent_presets
from core.application.application import DeepCodeApplication
@@ -207,10 +209,12 @@ def __init__(
connection: ConnectionState,
*,
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
+ service_info: dict[str, Any] | None = None,
) -> None:
self.application = application
self.connection = connection
self.max_message_bytes = max_message_bytes
+ self.service_info = service_info
self._handlers: dict[str, Handler] = {
rpc_methods.INITIALIZE: self._initialize,
rpc_methods.SHUTDOWN: self._shutdown,
@@ -225,6 +229,10 @@ def __init__(
rpc_methods.PROVIDER_UPSERT: self._provider_upsert,
rpc_methods.PROVIDER_REMOVE: self._provider_remove,
rpc_methods.PROVIDER_TEST: self._provider_test,
+ rpc_methods.PROVIDER_LOGIN_START: self._provider_login_start,
+ rpc_methods.PROVIDER_LOGIN_POLL: self._provider_login_poll,
+ rpc_methods.PROVIDER_LOGIN_CANCEL: self._provider_login_cancel,
+ rpc_methods.PROVIDER_LOGOUT: self._provider_logout,
rpc_methods.PROVIDER_DISCOVER: self._provider_discover,
rpc_methods.MODEL_LIST: self._model_list,
rpc_methods.PRESET_LIST: self._preset_list,
@@ -262,6 +270,11 @@ def __init__(
rpc_methods.THREAD_RESUME: self._thread_resume,
rpc_methods.THREAD_LIST: self._thread_list,
rpc_methods.THREAD_READ: self._thread_read,
+ rpc_methods.THREAD_EXECUTION_READ: self._thread_execution_read,
+ rpc_methods.THREAD_CONTEXT_CLEAR: self._thread_context_clear,
+ rpc_methods.THREAD_CONTEXT_COMPACT: self._thread_context_compact,
+ rpc_methods.TURN_LIST: self._turn_list,
+ rpc_methods.MODEL_REASONING: self._model_reasoning,
rpc_methods.THREAD_RENAME: self._thread_rename,
rpc_methods.THREAD_MODEL: self._thread_model,
rpc_methods.THREAD_EXECUTION_UPDATE: self._thread_execution_update,
@@ -279,6 +292,7 @@ def __init__(
rpc_methods.TURN_ENQUEUE: self._turn_enqueue,
rpc_methods.TURN_STEER: self._turn_steer,
rpc_methods.TURN_READ: self._turn_read,
+ rpc_methods.TURN_INPUT_READ: self._turn_input_read,
rpc_methods.TURN_INTERRUPT: self._turn_interrupt,
rpc_methods.TURN_RETRY: self._turn_retry,
rpc_methods.WORKFLOW_START: self._workflow_start,
@@ -300,6 +314,8 @@ def __init__(
rpc_methods.GIT_WORKTREE_CREATE: self._git_worktree_create,
rpc_methods.GIT_WORKTREE_REMOVE: self._git_worktree_remove,
rpc_methods.TERMINAL_CREATE: self._terminal_create,
+ rpc_methods.TERMINAL_LIST: self._terminal_list,
+ rpc_methods.TERMINAL_READ: self._terminal_read,
rpc_methods.TERMINAL_WRITE: self._terminal_write,
rpc_methods.TERMINAL_RESIZE: self._terminal_resize,
rpc_methods.TERMINAL_CLOSE: self._terminal_close,
@@ -348,6 +364,11 @@ def _initialize(self, params: Params) -> dict[str, Any]:
self.connection.initialize(str(client_name), client_surface)
return {
"protocolVersion": PROTOCOL_VERSION,
+ **(
+ {"serviceInfo": self.service_info}
+ if self.service_info is not None
+ else {}
+ ),
"serverInfo": {"name": "deepcode-app-server", "version": SERVER_VERSION},
"clientInfo": {
"name": client_name,
@@ -359,6 +380,11 @@ def _initialize(self, params: Params) -> dict[str, Any]:
"eventReplay": True,
"liveEvents": True,
"maxMessageBytes": self.max_message_bytes,
+ **(
+ {"requestRetry": retry_capabilities()}
+ if self.service_info is not None
+ else {}
+ ),
},
}
@@ -466,22 +492,46 @@ def _provider_remove(self, params: Params) -> dict[str, Any]:
expected_revision=params.string("expectedRevision", required=False),
)
+ def _provider_login_start(self, params: Params) -> dict:
+ params.only("connectionId", "openBrowser")
+ return self.application.llm.login_start(
+ str(params.string("connectionId")),
+ open_browser=params.boolean("openBrowser", default=False),
+ )
+
+ def _provider_login_poll(self, params: Params) -> dict:
+ params.only("flowId")
+ return self.application.llm.login_poll(str(params.string("flowId")))
+
+ def _provider_login_cancel(self, params: Params) -> dict:
+ params.only("flowId")
+ return self.application.llm.login_cancel(str(params.string("flowId")))
+
+ def _provider_logout(self, params: Params) -> dict:
+ params.only("connectionId")
+ return self.application.llm.logout(str(params.string("connectionId")))
+
def _provider_test(self, params: Params) -> dict[str, Any]:
- params.only("connectionId", "projectId", "model")
+ params.only("connectionId", "projectId", "model", "connection", "mode")
return self.application.llm.test(
str(params.string("connectionId")),
project_id=params.string("projectId", required=False),
model_id=params.string("model", required=False),
+ draft=params.object("connection", required=False),
+ mode=params.string("mode", required=False) or "quick",
)
def _provider_discover(self, params: Params) -> dict[str, Any]:
- params.only("connectionId", "template", "apiBase", "apiKey", "projectId")
+ params.only(
+ "connectionId", "template", "apiBase", "apiKey", "projectId", "connection"
+ )
return self.application.llm.discover_models(
connection_id=params.string("connectionId", required=False),
template=params.string("template", required=False),
api_base=params.string("apiBase", required=False),
api_key=params.string("apiKey", required=False),
project_id=params.string("projectId", required=False),
+ draft=params.object("connection", required=False),
)
def _model_list(self, params: Params) -> dict[str, Any]:
@@ -927,6 +977,12 @@ def _thread_start(self, params: Params) -> dict[str, Any]:
project_id,
title=str(params.string("title")),
mode=mode,
+ session_kind="tui"
+ if self.client_surface is ClientSurface.CLI
+ else "headless"
+ if self.client_surface is ClientSurface.HEADLESS
+ else "desktop",
+ inherit_default_preset=self.client_surface is not ClientSurface.HEADLESS,
connection_id=connection_id,
model=model,
reasoning_effort=reasoning_effort,
@@ -956,6 +1012,63 @@ def _thread_resume(self, params: Params) -> dict[str, Any]:
)
return {"thread": thread_view(thread)}
+ def _thread_execution_read(self, params: Params) -> dict[str, Any]:
+ params.only("threadId")
+ thread = self.application.threads.read(str(params.string("threadId")))
+ profile = self.application.llm.resolve(
+ thread.workspace_path,
+ ExecutionSelection(
+ connection_id=thread.connection_id,
+ model_id=thread.model,
+ reasoning_effort=thread.reasoning_effort,
+ context_window=thread.context_window,
+ ),
+ )
+ security = self.application.turns.execution_security_policy.resolve(thread)
+ return {
+ "executionProfile": profile.to_dict(),
+ "securityProfile": security.to_dict(),
+ }
+
+ def _thread_context_clear(self, params: Params) -> dict[str, Any]:
+ params.only("threadId")
+ self.application.turns.clear_live_context(str(params.string("threadId")))
+ return {}
+
+ def _thread_context_compact(self, params: Params) -> dict[str, Any]:
+ params.only("threadId")
+ return asyncio.run(
+ self.application.turns.compact_live_context(str(params.string("threadId")))
+ )
+
+ def _turn_list(self, params: Params) -> dict[str, Any]:
+ params.only("threadId", "limit", "offset", "state")
+ thread_id = str(params.string("threadId"))
+ self.application.threads.read(thread_id)
+ limit = params.integer("limit", default=100, minimum=1, maximum=500)
+ offset = params.integer("offset", default=0, maximum=1_000_000)
+ state = params.string("state", required=False) or "all"
+ if state not in {"all", "active", "executing"}:
+ raise InvalidParams("state must be all, active, or executing")
+ turns = self.application.turns.list_for_thread(
+ thread_id, limit=limit + 1, offset=offset, state=state
+ )
+ return {
+ "turns": [turn_view(turn) for turn in turns[:limit]],
+ "hasMore": len(turns) > limit,
+ }
+
+ def _model_reasoning(self, params: Params) -> dict[str, Any]:
+ params.only("projectId", "connectionId", "model")
+ capabilities = self.application.llm.model_reasoning(
+ str(params.string("connectionId")),
+ str(params.string("model")),
+ project_id=params.string("projectId", required=False),
+ )
+ return {
+ "reasoning": capabilities.to_dict() if capabilities is not None else None
+ }
+
def _thread_read(self, params: Params) -> dict[str, Any]:
params.only("threadId")
thread = self.application.threads.read(str(params.string("threadId")))
@@ -1098,7 +1211,12 @@ def _thread_goal_get(self, params: Params) -> dict[str, Any]:
params.only("threadId")
thread_id = str(params.string("threadId"))
goal = self.application.goals.read(thread_id)
- return self._goal_result(thread_id, goal)
+ return {
+ **self._goal_result(thread_id, goal),
+ "executionSettled": (
+ goal is None or self.application.goals.execution_settled(goal)
+ ),
+ }
def _thread_goal_set(self, params: Params) -> dict[str, Any]:
params.only(
@@ -1181,21 +1299,31 @@ def _thread_goal_pause(self, params: Params) -> dict[str, Any]:
return self._goal_result(thread_id, goal)
def _thread_goal_resume(self, params: Params) -> dict[str, Any]:
- params.only("threadId", "expectedGoalId")
+ params.only(
+ "threadId", "expectedGoalId", "connectionId", "model", "reasoningEffort"
+ )
thread_id = str(params.string("threadId"))
goal = self.application.goals.resume(
thread_id,
expected_goal_id=str(params.string("expectedGoalId")),
client_surface=self.client_surface,
+ connection_id=params.string("connectionId", required=False),
+ model=params.string("model", required=False),
+ reasoning_effort=params.string("reasoningEffort", required=False),
)
return self._goal_result(thread_id, goal)
def _thread_goal_continue(self, params: Params) -> dict[str, Any]:
- params.only("threadId", "expectedGoalId")
+ params.only(
+ "threadId", "expectedGoalId", "connectionId", "model", "reasoningEffort"
+ )
result = self.application.goals.continue_goal(
str(params.string("threadId")),
expected_goal_id=str(params.string("expectedGoalId")),
client_surface=self.client_surface,
+ connection_id=params.string("connectionId", required=False),
+ model=params.string("model", required=False),
+ reasoning_effort=params.string("reasoningEffort", required=False),
)
return {
**self._goal_result(result.goal.thread_id, result.goal),
@@ -1296,10 +1424,19 @@ def _turn_steer(self, params: Params) -> dict[str, Any]:
return {
"messageId": receipt.message_id,
"delivery": receipt.delivery,
+ "deliveryState": "accepted",
"duplicate": receipt.duplicate,
"turn": turn_view(receipt.turn),
}
+ def _turn_input_read(self, params: Params) -> dict[str, Any]:
+ params.only("threadId", "messageId")
+ item = self.application.turns.read_input(
+ str(params.string("threadId")),
+ str(params.string("messageId")),
+ )
+ return {"item": item_view(item) if item is not None else None}
+
def _turn_read(self, params: Params) -> dict[str, Any]:
params.only("turnId")
return self._turn_snapshot(
@@ -1416,18 +1553,20 @@ def _approval_respond(self, params: Params) -> dict[str, Any]:
return {"approval": approval_view(approval)}
def _event_replay(self, params: Params) -> dict[str, Any]:
- params.only("threadId", "after", "limit")
+ params.only("threadId", "after", "limit", "through")
thread_id = str(params.string("threadId"))
self.application.threads.read(thread_id)
page = self.application.events.replay_page(
thread_id,
after=params.integer("after", default=0, maximum=2**63 - 1),
limit=params.integer("limit", default=500, minimum=1, maximum=1000),
+ through=params.optional_integer("through", maximum=2**63 - 1),
)
return {
"events": [event_view(event) for event in page.events],
"nextAfter": page.next_after,
"hasMore": page.has_more,
+ "headSequence": page.head_sequence,
}
def _file_list(self, params: Params) -> dict[str, Any]:
@@ -1516,6 +1655,24 @@ def _terminal_create(self, params: Params) -> dict[str, Any]:
)
return {"terminal": terminal_info_view(info)}
+ def _terminal_list(self, params: Params) -> dict[str, Any]:
+ params.only("threadId")
+ return {
+ "terminals": self.application.terminals.list(str(params.string("threadId")))
+ }
+
+ def _terminal_read(self, params: Params) -> dict[str, Any]:
+ params.only("threadId", "terminalId", "offset", "limit", "through")
+ return self.application.terminals.read(
+ str(params.string("threadId")),
+ str(params.string("terminalId")),
+ offset=params.integer("offset", default=0, maximum=2**53 - 1),
+ limit=params.integer(
+ "limit", default=16 * 1024, minimum=4, maximum=64 * 1024
+ ),
+ through=params.optional_integer("through", maximum=2**53 - 1),
+ )
+
def _terminal_write(self, params: Params) -> dict[str, Any]:
params.only("threadId", "terminalId", "data")
written = self.application.terminals.write(
diff --git a/app_server/host.py b/app_server/host.py
new file mode 100644
index 000000000..651ed7524
--- /dev/null
+++ b/app_server/host.py
@@ -0,0 +1,147 @@
+"""Application ownership and shared notifications for RPC transports."""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import Callable
+from contextlib import ExitStack
+from typing import Any
+
+from app_server.config_watch import ConfigFileWatcher
+from app_server.peer import RpcPeer
+from app_server.protocol import notifications
+from app_server.protocol.codec import DEFAULT_MAX_MESSAGE_BYTES
+from core.application.application import DeepCodeApplication
+
+
+class ServiceHost:
+ """Own one application until explicitly closed, even with no clients.
+
+ Transports own their I/O and authentication. A peer's ``shutdown`` request
+ ends only that peer; the private stdio wrapper closes its host separately.
+ """
+
+ def __init__(
+ self,
+ application: DeepCodeApplication,
+ *,
+ max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
+ notification_capacity: int = 256,
+ ) -> None:
+ self.application = application
+ self.max_message_bytes = max_message_bytes
+ self.notification_capacity = notification_capacity
+ self._lock = threading.RLock()
+ self._close_lock = threading.Lock()
+ self._peers: set[RpcPeer] = set()
+ self._resources = ExitStack()
+ self._resources.callback(self._close_application)
+ self._started = False
+ self._closed = False
+ self._application_closed = False
+
+ def __enter__(self) -> ServiceHost:
+ self.start()
+ return self
+
+ def __exit__(self, *_exc: object) -> None:
+ self.close()
+
+ def start(self) -> None:
+ try:
+ with self._lock:
+ if self._closed:
+ raise RuntimeError("Service host is closed")
+ if self._started:
+ return
+ app = self.application
+ token = app.terminals.subscribe(self._publish)
+ self._resources.callback(app.terminals.unsubscribe, token)
+ token = app.skills.subscribe_changes(
+ lambda project_id: self._publish(
+ notifications.SKILLS_CHANGED, {"projectId": project_id}
+ )
+ )
+ self._resources.callback(app.skills.unsubscribe_changes, token)
+ token = app.plugins.subscribe_changes(
+ lambda _discovery: self._publish(notifications.PLUGINS_CHANGED, {})
+ )
+ self._resources.callback(app.plugins.unsubscribe_changes, token)
+ token = app.mcp.subscribe_changes(
+ lambda: self._publish(notifications.MCP_CHANGED, {})
+ )
+ self._resources.callback(app.mcp.unsubscribe_changes, token)
+ watcher = ConfigFileWatcher(
+ app.settings.store,
+ lambda revision: self._publish(
+ notifications.SETTINGS_CHANGED, {"configRevision": revision}
+ ),
+ )
+ self._resources.callback(watcher.stop)
+ watcher.start()
+ self._started = True
+ except BaseException:
+ self.close()
+ raise
+
+ def connect(
+ self,
+ send: Callable[[bytes], None],
+ *,
+ service_info: dict[str, Any] | None = None,
+ ) -> RpcPeer:
+ """Attach a transport whose writer accepts complete encoded frames."""
+ self.start()
+ with self._lock:
+ if self._closed:
+ raise RuntimeError("Service host is closed")
+ peer = RpcPeer(
+ self.application,
+ send,
+ on_close=self._forget_peer,
+ max_message_bytes=self.max_message_bytes,
+ notification_capacity=self.notification_capacity,
+ service_info=service_info,
+ )
+ self._peers.add(peer)
+ try:
+ peer.start()
+ except BaseException:
+ peer.close()
+ raise
+ return peer
+
+ def close(self) -> None:
+ with self._close_lock:
+ with self._lock:
+ if self._closed:
+ peers = ()
+ resources = None
+ else:
+ self._closed = True
+ peers = tuple(self._peers)
+ self._peers.clear()
+ resources = self._resources.pop_all()
+ if resources is None:
+ if not self._application_closed:
+ self._close_application()
+ return
+ try:
+ for peer in peers:
+ peer.close()
+ finally:
+ resources.close()
+
+ def _close_application(self) -> None:
+ self.application.close()
+ self._application_closed = True
+
+ def _forget_peer(self, peer: RpcPeer) -> None:
+ with self._lock:
+ self._peers.discard(peer)
+
+ def _publish(self, method: str, payload: dict[str, Any]) -> None:
+ with self._lock:
+ peers = tuple(self._peers)
+ for peer in peers:
+ peer.notify(method, payload)
diff --git a/app_server/launchd.py b/app_server/launchd.py
new file mode 100644
index 000000000..1b183f9e3
--- /dev/null
+++ b/app_server/launchd.py
@@ -0,0 +1,227 @@
+"""macOS user LaunchAgents for the existing DeepCode service executable."""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import plistlib
+import re
+import subprocess
+from pathlib import Path
+from xml.parsers.expat import ExpatError
+
+from app_server.service_client import ServiceOperationError
+from app_server.service_state import (
+ ServiceFiles,
+ service_command,
+ service_command_port,
+ service_environment,
+ service_working_directory,
+ shell_only_variables,
+)
+from core.config import deepcode_home
+from core.private_storage import open_existing_private_file, open_private_file
+
+
+class LaunchAgent:
+ """OS adapter; the service CLI owns management locking and task draining."""
+
+ name = "LaunchAgent"
+
+ def __init__(self, files: ServiceFiles, *, directory: Path | None = None) -> None:
+ self.files = files
+ identity = hashlib.sha256(str(files.database).encode()).hexdigest()[:16]
+ self.label = f"ai.deepcode.service.{identity}"
+ self.domain = f"gui/{os.getuid()}"
+ self.target = f"{self.domain}/{self.label}"
+ self.path = (
+ directory or Path.home() / "Library" / "LaunchAgents"
+ ) / f"{self.label}.plist"
+
+ @staticmethod
+ def _run(*args: str) -> subprocess.CompletedProcess:
+ try:
+ return subprocess.run(
+ ["/bin/launchctl", *args], capture_output=True, text=True, timeout=15
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise ServiceOperationError(
+ "launchctl did not finish; inspect service doctor before retrying"
+ ) from exc
+
+ def available(self) -> bool:
+ return self._run("print", self.domain).returncode == 0
+
+ def job(self) -> dict:
+ result = self._run("print", self.target)
+ if result.returncode:
+ if (
+ "Could not find service" in result.stderr
+ or "Could not find domain" in result.stderr
+ ):
+ return {"loaded": False, "pid": None}
+ raise ServiceOperationError(
+ f"Cannot inspect LaunchAgent: {result.stderr.strip()}"
+ )
+ pid = re.search(r"^\s*pid = (\d+)\s*$", result.stdout, re.MULTILINE)
+ return {"loaded": True, "pid": int(pid[1]) if pid else None}
+
+ def read(self) -> dict | None:
+ try:
+ with os.fdopen(open_existing_private_file(self.path), "rb") as stream:
+ data = stream.read(65_537)
+ except FileNotFoundError:
+ return None
+ if len(data) > 65_536:
+ raise ServiceOperationError("LaunchAgent file is too large")
+ try:
+ value = plistlib.loads(data)
+ except (ValueError, plistlib.InvalidFileException, ExpatError) as exc:
+ raise ServiceOperationError("Invalid DeepCode LaunchAgent plist") from exc
+ if not isinstance(value, dict) or value.get("Label") != self.label:
+ raise ServiceOperationError(
+ "LaunchAgent identity does not match this database"
+ )
+ arguments = value.get("ProgramArguments")
+ try:
+ service_command_port(arguments, self.files.database)
+ except ValueError as exc:
+ raise ServiceOperationError(
+ "LaunchAgent command has changed; stop and reinstall it"
+ ) from exc
+ if (
+ not isinstance(value.get("WorkingDirectory"), str)
+ or not Path(value["WorkingDirectory"]).is_absolute()
+ or value.get("KeepAlive") != {"SuccessfulExit": False}
+ or value.get("RunAtLoad") is not True
+ or not isinstance(value.get("EnvironmentVariables"), dict)
+ ):
+ raise ServiceOperationError(
+ "LaunchAgent configuration has changed; stop and reinstall it"
+ )
+ return value
+
+ def install(self, *, port: int, path: str | None = None) -> dict:
+ if not 0 <= port <= 65535:
+ raise ValueError("port must be between 0 and 65535")
+ environment = service_environment(path)
+ command = service_command(self.files, port)
+ value = {
+ "Label": self.label,
+ "ProgramArguments": command,
+ "WorkingDirectory": str(service_working_directory(command)),
+ "EnvironmentVariables": environment,
+ "RunAtLoad": True,
+ "KeepAlive": {"SuccessfulExit": False},
+ "ThrottleInterval": 10,
+ "ExitTimeOut": 30,
+ "ProcessType": "Background",
+ }
+ previous = self.read()
+ if previous != value and self.job()["loaded"]:
+ raise ServiceOperationError(
+ "Stop the loaded service before updating its LaunchAgent"
+ )
+ if previous != value:
+ # LaunchAgents is a shared user directory; do not chmod it as though
+ # DeepCode owned every agent in it. Only our plist is private.
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = self.path.with_suffix(".plist.tmp")
+ try:
+ fd = open_private_file(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
+ with os.fdopen(fd, "wb") as stream:
+ plistlib.dump(value, stream)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, self.path)
+ finally:
+ temporary.unlink(missing_ok=True)
+ return {"installed": True, "atLogin": True, "path": str(self.path)}
+
+ def start(self, *, port: int | None = None) -> None:
+ value = self.read()
+ if value is None:
+ raise ServiceOperationError("No LaunchAgent installed")
+ if port is not None and port != service_command_port(
+ value["ProgramArguments"], self.files.database
+ ):
+ raise ServiceOperationError(
+ "Requested port differs from the installed LaunchAgent; reinstall with the new port"
+ )
+ if not self.available():
+ raise ServiceOperationError(
+ "No macOS desktop login session is available; use deepcode serve --foreground"
+ )
+ for key in ("ProgramArguments", "WorkingDirectory"):
+ target = value[key][0] if key == "ProgramArguments" else value[key]
+ if not Path(target).exists():
+ raise ServiceOperationError(
+ f"Installed service path is missing: {target}; reinstall the LaunchAgent"
+ )
+ if self.job()["loaded"]:
+ args = ("kickstart", self.target)
+ else:
+ args = ("bootstrap", self.domain, str(self.path))
+ result = self._run(*args)
+ if result.returncode:
+ raise ServiceOperationError(
+ f"Cannot start LaunchAgent: {result.stderr.strip()}"
+ )
+
+ def unload(self) -> None:
+ if not self.job()["loaded"]:
+ return
+ result = self._run("bootout", self.target)
+ if result.returncode and self.job()["loaded"]:
+ raise ServiceOperationError(
+ f"Cannot unload LaunchAgent: {result.stderr.strip()}"
+ )
+
+ def uninstall(self) -> dict:
+ if self.job()["loaded"]:
+ raise ServiceOperationError("Stop the LaunchAgent before uninstalling it")
+ self.read() # Refuse to remove an unrelated/corrupt entry.
+ self.path.unlink(missing_ok=True)
+ return {"installed": False, "atLogin": False, "path": str(self.path)}
+
+ def doctor(self) -> dict:
+ checks = []
+ try:
+ value = self.read()
+ except ServiceOperationError as exc:
+ value = None
+ checks.append({"name": "configuration", "ok": False, "message": str(exc)})
+ job = self.job()
+ if value:
+ executable = value["ProgramArguments"][0]
+ directory = value["WorkingDirectory"]
+ checks.extend(
+ [
+ {
+ "name": "executable",
+ "ok": os.path.isfile(executable)
+ and os.access(executable, os.X_OK),
+ "path": executable,
+ },
+ {
+ "name": "workingDirectory",
+ "ok": Path(directory).is_dir(),
+ "path": directory,
+ },
+ {
+ "name": "runtimeHome",
+ "ok": value["EnvironmentVariables"].get("DEEPCODE_HOME")
+ == str(deepcode_home()),
+ },
+ ]
+ )
+ return {
+ "platform": "macos",
+ "installed": self.path.exists(),
+ "atLogin": value is not None,
+ "path": str(self.path),
+ "guiSessionAvailable": self.available(),
+ **job,
+ "checks": checks,
+ "shellOnlyVariables": shell_only_variables(),
+ }
diff --git a/app_server/managed_entry.py b/app_server/managed_entry.py
new file mode 100644
index 000000000..16c16d260
--- /dev/null
+++ b/app_server/managed_entry.py
@@ -0,0 +1,49 @@
+"""Read a private supervisor environment and enter the existing service host."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+from core.private_storage import open_existing_private_file
+
+ENVIRONMENT_KEYS = frozenset({"DEEPCODE_HOME", "DEEPCODE_SESSIONS_DIR", "PATH"})
+
+
+def read_configuration(path: Path) -> dict:
+ with os.fdopen(open_existing_private_file(path), "r", encoding="utf-8") as stream:
+ raw = stream.read(65537)
+ if len(raw) > 65536:
+ raise ValueError("Managed service configuration is too large")
+ value = json.loads(raw)
+ if (
+ not isinstance(value, dict)
+ or value.get("schemaVersion") != 1
+ or not isinstance(value.get("environment"), dict)
+ or set(value["environment"]) - ENVIRONMENT_KEYS
+ or any(
+ not isinstance(item, str) or "\x00" in item
+ for item in value["environment"].values()
+ )
+ or not isinstance(value.get("database"), str)
+ or not Path(value["database"]).is_absolute()
+ or type(value.get("port")) is not int
+ or not 0 <= value["port"] <= 65535
+ ):
+ raise ValueError("Invalid managed service configuration")
+ return value
+
+
+def run(path: Path) -> int:
+ value = read_configuration(path)
+ from app_server.service_state import ServiceFiles
+
+ if path.absolute().parent != ServiceFiles(Path(value["database"])).directory:
+ raise ValueError("Supervisor configuration does not belong to this database")
+ os.environ.update(value["environment"])
+ from app_server.service import main
+
+ return main(
+ ["--database", value["database"], "--port", str(value["port"]), "--log-file"]
+ )
diff --git a/app_server/native_client.py b/app_server/native_client.py
new file mode 100644
index 000000000..244677b6b
--- /dev/null
+++ b/app_server/native_client.py
@@ -0,0 +1,189 @@
+"""Authenticated service RPC for native clients; never owns an application."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from collections.abc import Callable
+from typing import Any
+
+import aiohttp
+
+from app_server.errors import RpcError
+from app_server.protocol.codec import DEFAULT_MAX_MESSAGE_BYTES, encode_message
+from app_server.service_client import ServiceClient, ServiceUnavailable
+from app_server.service_state import ServiceFiles
+from core.version import __version__
+
+
+class NativeRpcClient:
+ """One connection on one event loop; no implicit mutation replay or fallback."""
+
+ def __init__(
+ self, files: ServiceFiles, *, notify: Callable[[dict], None] | None = None
+ ):
+ self.files = files
+ self.notify = notify or (lambda _: None)
+ self.info: dict[str, Any] | None = None
+ self._session: aiohttp.ClientSession | None = None
+ self._socket: aiohttp.ClientWebSocketResponse | None = None
+ self._reader: asyncio.Task | None = None
+ self._pending: dict[int, asyncio.Future] = {}
+ self._serial = 0
+ self.closed = asyncio.Event()
+
+ async def connect(
+ self, initialize: dict, *, start: bool = False, port: int | None = None
+ ) -> dict:
+ if self._session is not None:
+ raise RuntimeError("connection already opened")
+ if start:
+ from cli.service_cli import start_service
+
+ await asyncio.to_thread(start_service, self.files, port=port)
+ # Authenticate the discovered listener before releasing any native secret.
+ status = await asyncio.to_thread(ServiceClient(self.files).call, "status")
+ found = self.files.read()
+ if found is None or found[0].instance_id != status["instanceId"]:
+ raise ServiceUnavailable("Service changed while connecting; reconnect")
+ record, token = found
+ if record.version != __version__:
+ raise RpcError(
+ -32003,
+ "The running service and this client have different versions. "
+ "Finish active work and upgrade/restart the service explicitly.",
+ stable_code="SERVICE_VERSION_MISMATCH",
+ )
+ self._session = aiohttp.ClientSession(
+ trust_env=False,
+ timeout=aiohttp.ClientTimeout(total=None, sock_connect=5),
+ )
+ try:
+ self._socket = await self._session.ws_connect(
+ record.url + "/api/rpc",
+ headers={
+ "Authorization": f"Bearer {token}",
+ "X-DeepCode-Instance": record.instance_id,
+ },
+ max_msg_size=DEFAULT_MAX_MESSAGE_BYTES,
+ heartbeat=20,
+ )
+ self._reader = asyncio.create_task(self._read())
+ self.info = await self.request("initialize", initialize)
+ identity = self.info.get("serviceInfo", {})
+ if (
+ self.info.get("protocolVersion") != "1.0"
+ or identity.get("instanceId") != record.instance_id
+ ):
+ raise ServiceUnavailable(
+ "Service handshake does not match the authenticated instance"
+ )
+ return self.info
+ except BaseException:
+ await self.close()
+ raise
+
+ async def request(
+ self, method: str, params: dict, *, timeout: float | None = None
+ ) -> Any:
+ socket = self._socket
+ if socket is None or socket.closed or self.closed.is_set():
+ raise RpcError(
+ -32000,
+ "Service disconnected; request was not sent",
+ stable_code="NOT_CONNECTED",
+ )
+ if len(self._pending) >= 64:
+ raise RpcError(
+ -32000, "Too many pending service requests", stable_code="CLIENT_BUSY"
+ )
+ self._serial += 1
+ request_id = self._serial
+ encoded = encode_message(
+ {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
+ )
+ if len(encoded) > DEFAULT_MAX_MESSAGE_BYTES:
+ raise RpcError(
+ -32600,
+ "Request exceeds the service message limit",
+ stable_code="INVALID_REQUEST",
+ )
+ future = asyncio.get_running_loop().create_future()
+ self._pending[request_id] = future
+ sent = False
+ try:
+ async with asyncio.timeout(
+ timeout
+ if timeout is not None
+ else (120 if method == "provider/test" else 25)
+ ):
+ # Once writing starts, failure cannot prove non-admission.
+ sent = True
+ await socket.send_str(encoded.decode())
+ return await future
+ except (TimeoutError, aiohttp.ClientError, ConnectionError) as exc:
+ policy = (self.info or {}).get("capabilities", {}).get("requestRetry", {})
+ read_only = (
+ method in policy.get("readMethods", []) or method == "initialize"
+ )
+ raise RpcError(
+ -32000,
+ "Service response was lost. Reconnect and inspect the current state before retrying.",
+ stable_code="CONNECTION_LOST"
+ if read_only or not sent
+ else "RESULT_UNKNOWN",
+ data={"retryable": read_only},
+ ) from exc
+ finally:
+ self._pending.pop(request_id, None)
+
+ async def _read(self) -> None:
+ try:
+ async for frame in self._socket:
+ if frame.type != aiohttp.WSMsgType.TEXT:
+ break
+ message = json.loads(frame.data)
+ if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
+ raise ValueError("Invalid service frame")
+ if "id" in message:
+ future = self._pending.get(message["id"])
+ if future is None or future.done():
+ continue
+ if "error" in message:
+ error = message["error"]
+ future.set_exception(
+ RpcError(
+ error["code"],
+ error["message"],
+ stable_code=error.get("data", {}).get(
+ "code", "RPC_ERROR"
+ ),
+ data=error.get("data"),
+ )
+ )
+ elif "result" in message:
+ future.set_result(message["result"])
+ else:
+ raise ValueError("Invalid service response")
+ elif isinstance(message.get("method"), str):
+ self.notify(message)
+ else:
+ raise ValueError("Invalid service notification")
+ finally:
+ self.closed.set()
+ for future in self._pending.values():
+ if not future.done():
+ future.set_exception(ConnectionError("Service connection closed"))
+
+ async def close(self) -> None:
+ self.closed.set()
+ if self._socket is not None:
+ try:
+ await asyncio.wait_for(self._socket.close(), 3)
+ except TimeoutError:
+ pass
+ if self._reader is not None:
+ self._reader.cancel()
+ await asyncio.gather(self._reader, return_exceptions=True)
+ if self._session is not None:
+ await self._session.close()
diff --git a/app_server/peer.py b/app_server/peer.py
new file mode 100644
index 000000000..2dbcf74c9
--- /dev/null
+++ b/app_server/peer.py
@@ -0,0 +1,311 @@
+"""One RPC client's requests and bounded notifications, independent of its host."""
+
+from __future__ import annotations
+
+import logging
+import threading
+from collections import deque
+from collections.abc import Callable
+from typing import Any
+
+from app_server.connection import ConnectionState
+from app_server.dispatcher import Dispatcher
+from app_server.errors import RpcError, from_application_error
+from app_server.protocol import methods as rpc_methods
+from app_server.protocol import notifications as rpc_notifications
+from app_server.protocol.codec import (
+ DEFAULT_MAX_MESSAGE_BYTES,
+ decode_request,
+ encode_message,
+)
+from app_server.protocol.models import Request, Response, notification
+from core.application.application import DeepCodeApplication
+from core.application.errors import ApplicationError
+from core.application.event_service import DeliveryBatch
+from core.application.views import event_view
+
+logger = logging.getLogger(__name__)
+
+
+class RpcPeer:
+ """Serialize one client's output; disconnect never closes the application.
+
+ ``send`` belongs to the transport and must either complete or raise on
+ disconnection. Transport adapters own cancellation of blocked socket/pipe I/O.
+ Host notifications only enter a bounded queue, never write to a client.
+ """
+
+ def __init__(
+ self,
+ application: DeepCodeApplication,
+ send: Callable[[bytes], None],
+ *,
+ on_close: Callable[[RpcPeer], None],
+ max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
+ notification_capacity: int = 256,
+ service_info: dict[str, Any] | None = None,
+ ) -> None:
+ if notification_capacity < 1:
+ raise ValueError("notification_capacity must be positive")
+ self.application = application
+ self.max_message_bytes = max_message_bytes
+ self.connection = ConnectionState(application.broker)
+ self.dispatcher = Dispatcher(
+ application,
+ self.connection,
+ max_message_bytes=max_message_bytes,
+ service_info=service_info,
+ )
+ self._send = send
+ self._on_close = on_close
+ self._delivery_lock = threading.RLock()
+ self._queue_lock = threading.Lock()
+ self._pending: deque[dict[str, Any]] = deque(maxlen=notification_capacity)
+ self._dropped = 0
+ self._stop = threading.Event()
+ self._pump = threading.Thread(
+ target=self._pump_events, name="deepcode-event-pump", daemon=True
+ )
+
+ @property
+ def closed(self) -> bool:
+ return self._stop.is_set()
+
+ def start(self) -> None:
+ self._pump.start()
+
+ def close(self) -> None:
+ with self._delivery_lock:
+ if self.closed:
+ return
+ self._stop.set()
+ with self._queue_lock:
+ self._pending.clear()
+ self.connection.close()
+ self._on_close(self)
+ if (
+ self._pump.ident is not None
+ and threading.current_thread() is not self._pump
+ ):
+ self._pump.join(timeout=1.0)
+
+ def notify(self, method: str, payload: dict[str, Any]) -> None:
+ with self._queue_lock:
+ if self.closed or not self.connection.initialized:
+ return
+ if len(self._pending) == self._pending.maxlen:
+ self._dropped += 1
+ self._pending.append(notification(method, payload))
+
+ def receive(
+ self, raw: bytes, *, before_dispatch: Callable[[Request], None] | None = None
+ ) -> None:
+ """Dispatch one complete frame; responses precede its queued events."""
+ try:
+ with self._delivery_lock:
+ if self.closed or self.connection.shutting_down:
+ return
+ request = None
+ try:
+ request = decode_request(raw, max_bytes=self.max_message_bytes)
+ if before_dispatch is not None:
+ before_dispatch(request)
+ result = self.dispatcher.dispatch(request)
+ except ApplicationError as exc:
+ if request is not None and request.has_id:
+ self._write_error(request.id, from_application_error(exc))
+ except RpcError as exc:
+ if request is None or request.has_id:
+ self._write_error(
+ request.id if request is not None else None, exc
+ )
+ except Exception:
+ logger.exception("Unhandled App Server request failure")
+ if request is not None and request.has_id:
+ self._write_error(
+ request.id,
+ RpcError(
+ -32603, "internal error", stable_code="INTERNAL_ERROR"
+ ),
+ )
+ else:
+ if request.has_id:
+ self._write_response(
+ Response(id=request.id, result=result).to_dict(),
+ method=request.method,
+ )
+ self._drain_events()
+ except (BrokenPipeError, OSError, ValueError):
+ self.close()
+ raise
+ if self.connection.shutting_down:
+ self.close()
+
+ def reject_oversized_frame(self) -> None:
+ try:
+ with self._delivery_lock:
+ if not self.closed:
+ self._write_error(
+ None,
+ RpcError(
+ -32600,
+ "message exceeds the configured size limit",
+ stable_code="INVALID_REQUEST",
+ ),
+ )
+ except (BrokenPipeError, OSError, ValueError):
+ self.close()
+ raise
+
+ def _pump_events(self) -> None:
+ try:
+ while not self.closed:
+ token = self.connection.subscription_token
+ if token is None:
+ self._stop.wait(0.05)
+ else:
+ self.application.broker.wait_for_events(token, timeout=0.25)
+ with self._delivery_lock:
+ if self.closed:
+ return
+ self._drain_events()
+ except (BrokenPipeError, OSError, ValueError):
+ logger.debug("App Server client disconnected during event delivery")
+ self.close()
+
+ def _drain_events(self) -> None:
+ with self._queue_lock:
+ pending = tuple(self._pending)
+ dropped = self._dropped
+ self._pending.clear()
+ self._dropped = 0
+ if dropped:
+ self._write_notification(
+ notification(
+ rpc_notifications.SERVER_WARNING,
+ {
+ "code": "NOTIFICATION_QUEUE_OVERFLOW",
+ "dropped": dropped,
+ "replayRequired": True,
+ },
+ )
+ )
+ for message in pending:
+ self._write_notification(message)
+ token = self.connection.subscription_token
+ if token is not None:
+ self._write_batch(self.application.broker.drain(token))
+
+ def _write_batch(self, batch: DeliveryBatch) -> None:
+ """Write one already-drained live batch while the caller owns the sink lock."""
+ if batch.dropped:
+ self._write(
+ notification(
+ rpc_notifications.SERVER_WARNING,
+ {
+ "code": "EVENT_QUEUE_OVERFLOW",
+ "dropped": batch.dropped,
+ "replayRequired": True,
+ },
+ ),
+ )
+ for event in batch.events:
+ method = (
+ rpc_notifications.THREAD_UPDATED
+ if event.type.startswith("thread.")
+ else event.type
+ )
+ self._write_notification(notification(method, event_view(event)))
+
+ def _write_error(self, request_id: Any, error: RpcError) -> None:
+ self._write(
+ Response(id=request_id, error=error.payload()).to_dict(),
+ )
+
+ def _write_response(
+ self,
+ message: dict[str, Any],
+ *,
+ method: str | None = None,
+ ) -> None:
+ encoded = encode_message(message)
+ if len(encoded) <= self.max_message_bytes:
+ self._send(encoded)
+ return
+ if method == rpc_methods.EVENT_REPLAY:
+ replay_page = self._fit_replay_response(message)
+ if replay_page is not None:
+ self._send(replay_page)
+ return
+ if method == rpc_methods.INITIALIZE:
+ # A rejected handshake must not leave a subscribed, initialized client.
+ self.connection.close()
+ request_id = message.get("id")
+ error = RpcError(
+ -32004,
+ "response exceeds the configured message limit",
+ stable_code="RESPONSE_TOO_LARGE",
+ data={"maxMessageBytes": self.max_message_bytes},
+ )
+ self._write(
+ Response(id=request_id, error=error.payload()).to_dict(),
+ )
+
+ def _fit_replay_response(self, message: dict[str, Any]) -> bytes | None:
+ """Return the largest replay prefix that fits one transport message."""
+
+ result = message.get("result")
+ if not isinstance(result, dict):
+ return None
+ events = result.get("events")
+ if not isinstance(events, list) or not events:
+ return None
+
+ best: bytes | None = None
+ low = 1
+ high = len(events)
+ while low <= high:
+ count = (low + high) // 2
+ last_event = events[count - 1]
+ if not isinstance(last_event, dict):
+ return None
+ sequence = last_event.get("sequence")
+ if isinstance(sequence, bool) or not isinstance(sequence, int):
+ return None
+ candidate = {
+ **message,
+ "result": {
+ **result,
+ "events": events[:count],
+ "nextAfter": sequence,
+ "hasMore": True,
+ },
+ }
+ encoded = encode_message(candidate)
+ if len(encoded) <= self.max_message_bytes:
+ best = encoded
+ low = count + 1
+ else:
+ high = count - 1
+ return best
+
+ def _write_notification(self, message: dict[str, Any]) -> None:
+ encoded = encode_message(message)
+ if len(encoded) <= self.max_message_bytes:
+ self._send(encoded)
+ return
+ warning = notification(
+ rpc_notifications.SERVER_WARNING,
+ {
+ "code": "NOTIFICATION_TOO_LARGE",
+ "dropped": 1,
+ "replayRequired": True,
+ },
+ )
+ self._write(warning)
+
+ def _write(self, message: dict[str, Any]) -> None:
+ encoded = encode_message(message)
+ if len(encoded) > self.max_message_bytes:
+ raise ValueError("outgoing message exceeds the configured size limit")
+ self._send(encoded)
diff --git a/app_server/protocol/codec.py b/app_server/protocol/codec.py
index 9a12464bb..b217e9b5c 100644
--- a/app_server/protocol/codec.py
+++ b/app_server/protocol/codec.py
@@ -23,7 +23,8 @@ def decode_request(
raise ParseError("message is not valid UTF-8") from exc
try:
value = json.loads(text)
- except json.JSONDecodeError as exc:
+ except (ValueError, RecursionError) as exc:
+ # Includes Python's integer-length guard as well as malformed JSON.
raise ParseError("invalid JSON") from exc
if not isinstance(value, dict) or value.get("jsonrpc") != "2.0":
raise InvalidRequest("expected a JSON-RPC 2.0 object")
diff --git a/app_server/protocol/methods.py b/app_server/protocol/methods.py
index 4690ed586..0f073f961 100644
--- a/app_server/protocol/methods.py
+++ b/app_server/protocol/methods.py
@@ -12,6 +12,10 @@
PROVIDER_LIST = "provider/list"
PROVIDER_UPSERT = "provider/upsert"
PROVIDER_REMOVE = "provider/remove"
+PROVIDER_LOGIN_START = "provider/login/start"
+PROVIDER_LOGIN_POLL = "provider/login/poll"
+PROVIDER_LOGIN_CANCEL = "provider/login/cancel"
+PROVIDER_LOGOUT = "provider/logout"
PROVIDER_TEST = "provider/test"
PROVIDER_DISCOVER = "provider/discover"
MODEL_LIST = "model/list"
@@ -50,6 +54,11 @@
THREAD_RESUME = "thread/resume"
THREAD_LIST = "thread/list"
THREAD_READ = "thread/read"
+THREAD_EXECUTION_READ = "thread/execution/read"
+THREAD_CONTEXT_CLEAR = "thread/context/clear"
+THREAD_CONTEXT_COMPACT = "thread/context/compact"
+TURN_LIST = "turn/list"
+MODEL_REASONING = "model/reasoning"
THREAD_RENAME = "thread/rename"
THREAD_MODEL = "thread/model"
THREAD_EXECUTION_UPDATE = "thread/execution/update"
@@ -67,6 +76,7 @@
TURN_ENQUEUE = "turn/enqueue"
TURN_STEER = "turn/steer"
TURN_READ = "turn/read"
+TURN_INPUT_READ = "turn/input/read"
TURN_INTERRUPT = "turn/interrupt"
TURN_RETRY = "turn/retry"
WORKFLOW_START = "workflow/start"
@@ -88,6 +98,8 @@
GIT_WORKTREE_CREATE = "git/worktree/create"
GIT_WORKTREE_REMOVE = "git/worktree/remove"
TERMINAL_CREATE = "terminal/create"
+TERMINAL_LIST = "terminal/list"
+TERMINAL_READ = "terminal/read"
TERMINAL_WRITE = "terminal/write"
TERMINAL_RESIZE = "terminal/resize"
TERMINAL_CLOSE = "terminal/close"
diff --git a/app_server/protocol/retry.py b/app_server/protocol/retry.py
new file mode 100644
index 000000000..bbb1a7893
--- /dev/null
+++ b/app_server/protocol/retry.py
@@ -0,0 +1,58 @@
+"""Explicit network retry contract; unlisted operations must not be replayed."""
+
+READ_METHODS = frozenset(
+ {
+ "project/list",
+ "project/read",
+ "settings/read",
+ "provider/list",
+ "provider/login/poll",
+ "model/list",
+ "preset/list",
+ "preset/current",
+ "skills/list",
+ "skill/read",
+ "plugins/list",
+ "hooks/list",
+ "mcp/list",
+ "mcp/presets",
+ "diagnostics/read",
+ "automation/list",
+ "automation/runs",
+ "thread/list",
+ "thread/read",
+ "thread/execution/read",
+ "turn/list",
+ "model/reasoning",
+ "thread/goal/get",
+ "turn/read",
+ "turn/input/read",
+ "terminal/list",
+ "terminal/read",
+ "workflow/read",
+ "workflow/list",
+ "artifact/list",
+ "artifact/read",
+ "event/replay",
+ "file/list",
+ "file/read",
+ "git/status",
+ "git/diff",
+ "test/discover",
+ }
+)
+
+KEYED_METHODS = {
+ "turn/start": "messageId",
+ "turn/enqueue": "messageId",
+ "turn/steer": "messageId",
+ "automation/run": "requestId",
+}
+
+
+def retry_capabilities() -> dict:
+ return {
+ "default": "never",
+ "readMethods": sorted(READ_METHODS),
+ "keyedMethods": dict(KEYED_METHODS),
+ }
diff --git a/app_server/runtime_install.py b/app_server/runtime_install.py
new file mode 100644
index 000000000..a40775259
--- /dev/null
+++ b/app_server/runtime_install.py
@@ -0,0 +1,49 @@
+"""Pin a bundled service outside an updater-owned Desktop installation."""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+from core.config import deepcode_home
+from core.file_lock import exclusive_file_lock
+from core.private_storage import ensure_private_directory
+from core.version import __version__
+
+
+def pinned_service_executable() -> Path:
+ """Publish a complete immutable onedir copy, without modifying older versions."""
+ executable = Path(sys.executable).absolute()
+ source = executable.parent
+ if not (source / "_internal").is_dir():
+ raise RuntimeError("The complete onedir App Server bundle is required")
+ # The executable includes the Python code; the manifest identifies separate
+ # frontend resources. Versioned copies survive Desktop updater replacement.
+ digest = hashlib.sha256(executable.read_bytes())
+ manifest = source / "_internal" / "app_server" / "web_assets" / "web-build.json"
+ digest.update(manifest.read_bytes())
+ root = ensure_private_directory(deepcode_home() / "runtimes")
+ destination = root / f"{__version__}-{digest.hexdigest()[:24]}"
+ target = destination / executable.name
+ if source.resolve() == destination.resolve():
+ return executable
+ with exclusive_file_lock(root / "install.lock"):
+ if target.is_file() and (destination / ".complete").is_file():
+ return target
+ if destination.exists():
+ raise RuntimeError(
+ "Incomplete pinned service installation; inspect the runtime directory"
+ )
+ staging = Path(tempfile.mkdtemp(prefix=".install-", dir=root))
+ try:
+ shutil.copytree(source, staging, dirs_exist_ok=True, symlinks=True)
+ (staging / ".complete").write_text(digest.hexdigest(), encoding="ascii")
+ os.rename(staging, destination)
+ finally:
+ if staging.exists():
+ shutil.rmtree(staging)
+ return target
diff --git a/app_server/runtime_probe.py b/app_server/runtime_probe.py
index 5023f5d96..25ced21ae 100644
--- a/app_server/runtime_probe.py
+++ b/app_server/runtime_probe.py
@@ -60,6 +60,8 @@ def verify_runtime() -> dict[str, Any]:
importlib.import_module(module)
bundled_skills = _verify_bundled_skills()
bundled_mcp_presets = [preset.id for preset in McpPresetCatalog().list()]
+ from app_server.web_surface import ASSET_DIRECTORY, read_web_build
+
return {
"ok": True,
"modules": list(RUNTIME_MODULES),
@@ -70,4 +72,7 @@ def verify_runtime() -> dict[str, Any]:
"skillCreator": "skill-creator" in bundled_skills,
"bundledMcpPresets": bundled_mcp_presets,
"version": __version__,
+ "webAssets": bool(
+ read_web_build() and (ASSET_DIRECTORY / "index.html").is_file()
+ ),
}
diff --git a/app_server/server.py b/app_server/server.py
index 67804f28f..42ccc83eb 100644
--- a/app_server/server.py
+++ b/app_server/server.py
@@ -1,32 +1,37 @@
-"""Single-client stdio JSON-RPC server."""
+"""Private stdio host and stream adapter for the shared RPC implementation."""
from __future__ import annotations
-import logging
-import threading
-from typing import Any, BinaryIO
+from typing import BinaryIO
-from app_server.config_watch import ConfigFileWatcher
-from app_server.connection import ConnectionState
-from app_server.dispatcher import Dispatcher
-from app_server.errors import RpcError, from_application_error
-from app_server.protocol import methods as rpc_methods
-from app_server.protocol import notifications as rpc_notifications
-from app_server.protocol.codec import (
- DEFAULT_MAX_MESSAGE_BYTES,
- decode_request,
- encode_message,
-)
-from app_server.protocol.models import Response, notification
+from app_server.host import ServiceHost
+from app_server.peer import RpcPeer
+from app_server.protocol.codec import DEFAULT_MAX_MESSAGE_BYTES
from core.application.application import DeepCodeApplication
-from core.application.errors import ApplicationError
-from core.application.event_service import DeliveryBatch
-from core.application.views import event_view
-logger = logging.getLogger(__name__)
+
+def serve_stdio(peer: RpcPeer, source: BinaryIO) -> int:
+ """Read one connection until EOF/shutdown; release only that connection."""
+ try:
+ while not peer.closed:
+ raw = source.readline(peer.max_message_bytes + 1)
+ if not raw:
+ break
+ if len(raw) > peer.max_message_bytes:
+ chunk = raw
+ while chunk and not chunk.endswith(b"\n"):
+ chunk = source.readline(64 * 1024)
+ peer.reject_oversized_frame()
+ continue
+ peer.receive(raw)
+ finally:
+ peer.close()
+ return 0
class AppServer:
+ """Preserve the dedicated stdio process's EOF/shutdown ownership contract."""
+
def __init__(
self,
application: DeepCodeApplication,
@@ -37,309 +42,11 @@ def __init__(
self.max_message_bytes = max_message_bytes
def serve(self, source: BinaryIO, sink: BinaryIO) -> int:
- connection = ConnectionState(self.application.broker)
- dispatcher = Dispatcher(
- self.application,
- connection,
- max_message_bytes=self.max_message_bytes,
- )
- delivery_lock = threading.RLock()
- stop_pump = threading.Event()
-
- def deliver_terminal(method: str, payload: dict[str, Any]) -> None:
- try:
- with delivery_lock:
- self._write_notification(sink, notification(method, payload))
- except (BrokenPipeError, OSError, ValueError):
- stop_pump.set()
-
- terminal_token = self.application.terminals.subscribe(deliver_terminal)
-
- def deliver_skill_change(project_id: str) -> None:
- try:
- with delivery_lock:
- self._write_notification(
- sink,
- notification(
- rpc_notifications.SKILLS_CHANGED,
- {"projectId": project_id},
- ),
- )
- except (BrokenPipeError, OSError, ValueError):
- stop_pump.set()
-
- skill_token = self.application.skills.subscribe_changes(deliver_skill_change)
-
- def deliver_plugin_change(_discovery) -> None:
- try:
- with delivery_lock:
- self._write_notification(
- sink,
- notification(rpc_notifications.PLUGINS_CHANGED, {}),
- )
- except (BrokenPipeError, OSError, ValueError):
- stop_pump.set()
-
- plugin_token = self.application.plugins.subscribe_changes(deliver_plugin_change)
-
- def deliver_mcp_change() -> None:
- try:
- with delivery_lock:
- self._write_notification(
- sink,
- notification(rpc_notifications.MCP_CHANGED, {}),
- )
- except (BrokenPipeError, OSError, ValueError):
- stop_pump.set()
-
- mcp_token = self.application.mcp.subscribe_changes(deliver_mcp_change)
-
- def deliver_settings_change(config_revision: str) -> None:
- try:
- with delivery_lock:
- self._write_notification(
- sink,
- notification(
- rpc_notifications.SETTINGS_CHANGED,
- {"configRevision": config_revision},
- ),
- )
- except (BrokenPipeError, OSError, ValueError):
- stop_pump.set()
-
- settings_watcher = ConfigFileWatcher(
- self.application.settings.store,
- deliver_settings_change,
- )
- settings_watcher.start()
- pump = threading.Thread(
- target=self._pump_events,
- args=(sink, connection, delivery_lock, stop_pump),
- name="deepcode-event-pump",
- daemon=True,
- )
- pump.start()
- try:
- while not connection.shutting_down:
- request = None
- raw = source.readline(self.max_message_bytes + 1)
- if not raw:
- break
- if len(raw) > self.max_message_bytes:
- self._discard_remainder(source, raw)
- with delivery_lock:
- self._write_error(
- sink,
- None,
- RpcError(
- -32600,
- "message exceeds the configured size limit",
- stable_code="INVALID_REQUEST",
- ),
- )
- continue
- with delivery_lock:
- try:
- request = decode_request(raw, max_bytes=self.max_message_bytes)
- result = dispatcher.dispatch(request)
- except ApplicationError as exc:
- if request is not None and request.has_id:
- self._write_error(
- sink, request.id, from_application_error(exc)
- )
- continue
- except RpcError as exc:
- request_id = (
- request.id
- if request is not None and request.has_id
- else None
- )
- if request is None or request.has_id:
- self._write_error(sink, request_id, exc)
- continue
- except Exception:
- logger.exception("Unhandled App Server request failure")
- if request is not None and request.has_id:
- self._write_error(
- sink,
- request.id,
- RpcError(
- -32603,
- "internal error",
- stable_code="INTERNAL_ERROR",
- ),
- )
- continue
-
- if request.has_id:
- self._write_response(
- sink,
- Response(id=request.id, result=result).to_dict(),
- method=request.method,
- )
- self._drain_events(sink, connection)
- finally:
- stop_pump.set()
- pump.join(timeout=1.0)
- self.application.terminals.unsubscribe(terminal_token)
- self.application.skills.unsubscribe_changes(skill_token)
- self.application.plugins.unsubscribe_changes(plugin_token)
- self.application.mcp.unsubscribe_changes(mcp_token)
- settings_watcher.stop()
- connection.close()
- self.application.close()
- return 0
-
- def _pump_events(
- self,
- sink: BinaryIO,
- connection: ConnectionState,
- delivery_lock: threading.RLock,
- stop: threading.Event,
- ) -> None:
- while not stop.is_set():
- token = connection.subscription_token
- if token is None:
- stop.wait(0.05)
- continue
- if not self.application.broker.wait_for_events(token, timeout=0.25):
- continue
- try:
- with delivery_lock:
- batch = self.application.broker.drain(token)
- self._write_batch(sink, batch)
- except (BrokenPipeError, OSError, ValueError):
- logger.exception("App Server event delivery failed")
- stop.set()
-
- def _drain_events(self, sink: BinaryIO, connection: ConnectionState) -> None:
- token = connection.subscription_token
- if token is None:
- return
- self._write_batch(sink, self.application.broker.drain(token))
-
- def _write_batch(self, sink: BinaryIO, batch: DeliveryBatch) -> None:
- """Write one already-drained live batch while the caller owns the sink lock."""
- if batch.dropped:
- self._write(
- sink,
- notification(
- rpc_notifications.SERVER_WARNING,
- {
- "code": "EVENT_QUEUE_OVERFLOW",
- "dropped": batch.dropped,
- "replayRequired": True,
- },
- ),
- )
- for event in batch.events:
- method = (
- rpc_notifications.THREAD_UPDATED
- if event.type.startswith("thread.")
- else event.type
- )
- self._write_notification(sink, notification(method, event_view(event)))
-
- @staticmethod
- def _discard_remainder(source: BinaryIO, first_chunk: bytes) -> None:
- chunk = first_chunk
- while chunk and not chunk.endswith(b"\n"):
- chunk = source.readline(64 * 1024)
-
- def _write_error(self, sink: BinaryIO, request_id: Any, error: RpcError) -> None:
- self._write(
- sink,
- Response(id=request_id, error=error.payload()).to_dict(),
- )
-
- def _write_response(
- self,
- sink: BinaryIO,
- message: dict[str, Any],
- *,
- method: str | None = None,
- ) -> None:
- encoded = encode_message(message)
- if len(encoded) <= self.max_message_bytes:
- self._write_encoded(sink, encoded)
- return
- if method == rpc_methods.EVENT_REPLAY:
- replay_page = self._fit_replay_response(message)
- if replay_page is not None:
- self._write_encoded(sink, replay_page)
- return
- request_id = message.get("id")
- error = RpcError(
- -32004,
- "response exceeds the configured message limit",
- stable_code="RESPONSE_TOO_LARGE",
- data={"maxMessageBytes": self.max_message_bytes},
- )
- self._write(
- sink,
- Response(id=request_id, error=error.payload()).to_dict(),
- )
-
- def _fit_replay_response(self, message: dict[str, Any]) -> bytes | None:
- """Return the largest replay prefix that fits one transport message."""
-
- result = message.get("result")
- if not isinstance(result, dict):
- return None
- events = result.get("events")
- if not isinstance(events, list) or not events:
- return None
-
- best: bytes | None = None
- low = 1
- high = len(events)
- while low <= high:
- count = (low + high) // 2
- last_event = events[count - 1]
- if not isinstance(last_event, dict):
- return None
- sequence = last_event.get("sequence")
- if isinstance(sequence, bool) or not isinstance(sequence, int):
- return None
- candidate = {
- **message,
- "result": {
- **result,
- "events": events[:count],
- "nextAfter": sequence,
- "hasMore": True,
- },
- }
- encoded = encode_message(candidate)
- if len(encoded) <= self.max_message_bytes:
- best = encoded
- low = count + 1
- else:
- high = count - 1
- return best
-
- def _write_notification(self, sink: BinaryIO, message: dict[str, Any]) -> None:
- encoded = encode_message(message)
- if len(encoded) <= self.max_message_bytes:
- self._write_encoded(sink, encoded)
- return
- warning = notification(
- rpc_notifications.SERVER_WARNING,
- {
- "code": "NOTIFICATION_TOO_LARGE",
- "dropped": 1,
- "replayRequired": True,
- },
- )
- self._write(sink, warning)
-
- def _write(self, sink: BinaryIO, message: dict[str, Any]) -> None:
- encoded = encode_message(message)
- if len(encoded) > self.max_message_bytes:
- raise ValueError("outgoing message exceeds the configured size limit")
- self._write_encoded(sink, encoded)
-
- @staticmethod
- def _write_encoded(sink: BinaryIO, encoded: bytes) -> None:
- sink.write(encoded)
- sink.flush()
+ def send(encoded: bytes) -> None:
+ sink.write(encoded)
+ sink.flush()
+
+ with ServiceHost(
+ self.application, max_message_bytes=self.max_message_bytes
+ ) as host:
+ return serve_stdio(host.connect(send), source)
diff --git a/app_server/service.py b/app_server/service.py
new file mode 100644
index 000000000..f40300715
--- /dev/null
+++ b/app_server/service.py
@@ -0,0 +1,459 @@
+"""Loopback management and business listener for one DeepCode application."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import hmac
+import logging
+from logging.handlers import RotatingFileHandler
+import math
+import os
+from pathlib import Path
+import secrets
+import signal
+import socket
+import threading
+import time
+
+from aiohttp import web
+
+from app_server.browser_auth import BrowserAuth
+from app_server.host import ServiceHost
+from app_server.errors import RpcError
+from app_server.protocol.codec import decode_request
+from app_server.service_client import ServiceClient, ServiceUnavailable
+from app_server.service_state import ServiceFiles, ServiceRecord, identity_proof
+from app_server.websocket import WebSocketTransport
+from app_server.web_surface import WebSurface
+from core.application.application import DeepCodeApplication
+from core.application.service_lifecycle import ServiceLifecycle
+from core.config import LoggerConfig
+from core.observability import setup_logging
+from core.persistence.database import default_database_path
+from core.private_storage import ensure_private_directory, open_private_file
+
+logger = logging.getLogger(__name__)
+MAX_CONTROL_BYTES = 16_384
+
+
+class _PrivateRotatingLog(RotatingFileHandler):
+ def _open(self):
+ descriptor = open_private_file(
+ Path(self.baseFilename), os.O_WRONLY | os.O_CREAT | os.O_APPEND
+ )
+ return os.fdopen(descriptor, "a", encoding=self.encoding)
+
+
+class ControlServer:
+ """Compose management and business transports with separate authorization."""
+
+ def __init__(self, host: ServiceHost, record: ServiceRecord, token: str) -> None:
+ self.host = host
+ self.record = record
+ self._token = token
+ self.lifecycle = ServiceLifecycle(host.application)
+ self.stopped = asyncio.Event()
+ self.interrupt = threading.Event()
+ self.phase = "ready"
+ self._operation = asyncio.Lock()
+ self.browser_auth = BrowserAuth(record.instance_id)
+ self.web_surface = WebSurface(
+ host.application, self.browser_auth, lambda: self.phase
+ )
+ self.business = WebSocketTransport(
+ host,
+ self.browser_auth,
+ native_authenticated=self._authenticated,
+ phase=lambda: self.phase,
+ service_info={
+ "instanceId": record.instance_id,
+ "schemaVersion": host.application.database.schema_version(),
+ "transport": "websocket",
+ "shutdownScope": "connection",
+ "frontendBuildId": (self.web_surface.build() or {}).get("buildId"),
+ },
+ )
+
+ def application(self) -> web.Application:
+ app = web.Application(
+ client_max_size=MAX_CONTROL_BYTES, middlewares=[self._local_only]
+ )
+ app.add_routes(
+ [
+ web.get("/health/live", self._health),
+ web.get("/health/ready", self._health),
+ web.get("/control/identity", self._identity),
+ web.post("/control/rpc", self._rpc),
+ web.post("/auth/exchange", self._exchange),
+ web.post("/auth/logout", self._logout),
+ web.get("/api/rpc", self.business.handle),
+ ]
+ )
+ app.add_routes(self.web_surface.routes())
+ app.on_shutdown.append(self.business.shutdown)
+ app.on_cleanup.append(self.business.cleanup)
+ app.on_response_prepare.append(self._private_response)
+ return app
+
+ @web.middleware
+ async def _local_only(self, request: web.Request, handler):
+ if request.headers.getall("Host", []) != [f"127.0.0.1:{self.record.port}"]:
+ raise web.HTTPForbidden(text="Local service management only")
+ origins = request.headers.getall("Origin", [])
+ browser_route = request.path.startswith(
+ ("/auth/", "/api/", "/assets/")
+ ) or request.path in {"/", "/index.html", "/web-build.json"}
+ if origins and (not browser_route or origins != [self.record.url]):
+ raise web.HTTPForbidden(text="Invalid browser origin")
+ if request.path.startswith("/auth/") and not origins:
+ raise web.HTTPForbidden(text="Browser origin required")
+ if (
+ request.path.startswith("/api/")
+ and request.method not in {"GET", "HEAD"}
+ and origins != [self.record.url]
+ ):
+ raise web.HTTPForbidden(text="Browser origin required")
+ return await handler(request)
+
+ async def _private_response(self, _request, response) -> None:
+ response.headers["Cache-Control"] = "no-store"
+ response.headers["Referrer-Policy"] = "no-referrer"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["Content-Security-Policy"] = (
+ f"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:; connect-src 'self' ws://127.0.0.1:{self.record.port}; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
+ )
+
+ async def _exchange(self, request: web.Request) -> web.Response:
+ if self.phase != "ready":
+ raise web.HTTPServiceUnavailable(text="Service is not ready")
+ if request.content_type != "application/json":
+ raise web.HTTPUnsupportedMediaType(text="JSON body required")
+ try:
+ body = await request.json()
+ except (ValueError, RecursionError):
+ raise web.HTTPBadRequest(text="Invalid exchange body") from None
+ if not isinstance(body, dict) or set(body) != {"ticket"}:
+ raise web.HTTPBadRequest(text="Expected a browser ticket")
+ session = self.browser_auth.exchange(body["ticket"])
+ response = web.json_response(
+ {"authenticated": True, "instanceId": self.record.instance_id}
+ )
+ response.set_cookie(
+ self.browser_auth.cookie_name,
+ session,
+ httponly=True,
+ samesite="Strict",
+ max_age=self.browser_auth.SESSION_TTL,
+ path="/",
+ )
+ return response
+
+ async def _logout(self, request: web.Request) -> web.Response:
+ session = self.browser_auth.require(request)
+ await self.business.revoke(session)
+ response = web.json_response({"authenticated": False})
+ response.del_cookie(self.browser_auth.cookie_name, path="/")
+ return response
+
+ async def _health(self, request: web.Request) -> web.Response:
+ ready = self.phase == "ready"
+ status = 200 if request.path == "/health/live" or ready else 503
+ return web.json_response({"status": self.phase}, status=status)
+
+ async def _identity(self, request: web.Request) -> web.Response:
+ challenge = request.query.get("challenge", "")
+ if len(challenge) != 32 or any(
+ char not in "0123456789abcdef" for char in challenge
+ ):
+ raise web.HTTPBadRequest(text="Invalid identity challenge")
+ return web.json_response(
+ {"proof": identity_proof(self._token, self.record.instance_id, challenge)}
+ )
+
+ async def status(self) -> dict:
+ return {
+ "instanceId": self.record.instance_id,
+ "phase": self.phase,
+ "pid": self.record.pid,
+ "url": self.record.url,
+ "version": self.record.version,
+ "protocolVersion": self.record.protocol_version,
+ **await asyncio.to_thread(self.lifecycle.activity),
+ }
+
+ def _authenticated(self, request: web.Request) -> bool:
+ return (
+ hmac.compare_digest(
+ request.headers.get("Authorization", "").encode(),
+ f"Bearer {self._token}".encode(),
+ )
+ and request.headers.get("X-DeepCode-Instance") == self.record.instance_id
+ )
+
+ async def _rpc(self, request: web.Request) -> web.Response:
+ if not self._authenticated(request):
+ raise web.HTTPUnauthorized(text="Invalid service credential")
+ try:
+ rpc = decode_request(await request.read(), max_bytes=MAX_CONTROL_BYTES)
+ except RpcError as exc:
+ return web.json_response(
+ {"jsonrpc": "2.0", "id": None, "error": exc.payload()}, status=400
+ )
+ if not rpc.has_id:
+ raise web.HTTPBadRequest(text="Service control requests require an id")
+ request_id = rpc.id
+ params = rpc.params
+ method = rpc.method
+ if method == "auth/issue" and not params:
+ if self.phase != "ready":
+ return self._error(request_id, "Service is not ready")
+ return web.json_response(
+ {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {
+ "instanceId": self.record.instance_id,
+ **self.browser_auth.issue(),
+ },
+ }
+ )
+ if method == "status" and not params:
+ return web.json_response(
+ {"jsonrpc": "2.0", "id": request_id, "result": await self.status()}
+ )
+ if method == "resume" and not params:
+ if self.phase != "drained" or self._operation.locked():
+ return self._error(
+ request_id, "Service is not awaiting supervisor stop"
+ )
+ async with self._operation:
+ await asyncio.to_thread(self.lifecycle.resume)
+ self.phase = "ready"
+ return web.json_response(
+ {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {"instanceId": self.record.instance_id, "accepted": True},
+ }
+ )
+ if method not in {"stop", "drain"}:
+ return self._error(request_id, "Unknown service operation")
+ timeout = params.get("timeout", 60.0)
+ cancel = params.get("cancelRunning", False)
+ if (
+ set(params) - {"timeout", "cancelRunning"}
+ or type(cancel) is not bool
+ or (method == "drain" and cancel)
+ or type(timeout) not in (int, float)
+ or not 0 <= timeout <= 300
+ or not math.isfinite(timeout)
+ ):
+ return self._error(request_id, "Invalid stop options")
+ if self._operation.locked() or self.phase not in {"ready", "drained"}:
+ return self._error(
+ request_id, "Another service stop is already in progress"
+ )
+ async with self._operation:
+ previous_phase = self.phase
+ self.phase = "draining"
+ deadline = time.monotonic() + timeout
+ try:
+ if not await self.business.wait_idle(timeout):
+ return self._error(
+ request_id, "RPC drain timed out; service is still running"
+ )
+ if not cancel and not await asyncio.to_thread(
+ self.lifecycle.drain,
+ max(0.0, deadline - time.monotonic()),
+ self.interrupt,
+ ):
+ return self._error(
+ request_id,
+ "Drain timed out; service is still running. Finish active work or explicitly cancel it.",
+ )
+ self.phase = "drained" if method == "drain" else "stopping"
+ response = web.json_response(
+ {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "result": {
+ "instanceId": self.record.instance_id,
+ "accepted": True,
+ },
+ }
+ )
+ if method == "drain":
+ return response
+ try:
+ await response.prepare(request)
+ await response.write_eof()
+ finally:
+ self.stopped.set()
+ return response
+ finally:
+ if self.phase == "draining":
+ self.phase = previous_phase
+
+ @staticmethod
+ def _error(request_id: int | str | None, message: str) -> web.Response:
+ return web.json_response(
+ {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {"code": -32000, "message": message},
+ }
+ )
+
+
+async def serve(files: ServiceFiles, port: int) -> None:
+ lease = files.acquire()
+ # A status probe briefly takes this lock too. Distinguish it from a real
+ # lifetime owner without waiting behind another running service forever.
+ for _ in range(10):
+ if lease is not None:
+ break
+ await asyncio.sleep(0.02)
+ lease = files.acquire()
+ if lease is None:
+ # Crash records survive their owner. A successful exit suppresses native
+ # supervisor recovery, so only an authenticated live owner permits it.
+ try:
+ await asyncio.to_thread(ServiceClient(files).call, "status", timeout=1)
+ except ServiceUnavailable as exc:
+ raise ServiceUnavailable(
+ "Service ownership is busy but no live owner could be verified"
+ ) from exc
+ logger.info("A DeepCode service already owns this database")
+ return
+ host = None
+ runner = None
+ control = None
+ installed_signals = []
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ loop = asyncio.get_running_loop()
+ try:
+ files.clear()
+ if os.name != "nt":
+ # Rebind after our old TCP connections enter TIME_WAIT. Do not use
+ # SO_REUSEPORT or Windows address sharing with another live listener.
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ listener.bind(("127.0.0.1", port))
+ except OSError as exc:
+ logger.error("Cannot listen on 127.0.0.1:%s: %s", port, exc.strerror)
+ raise
+ listener.listen(socket.SOMAXCONN)
+ listener.setblocking(False)
+ record = ServiceRecord(
+ secrets.token_hex(16),
+ str(files.database),
+ os.getpid(),
+ listener.getsockname()[1],
+ )
+ token = secrets.token_hex(32)
+ application = await asyncio.to_thread(
+ DeepCodeApplication.open,
+ files.database,
+ host_surface="service",
+ run_automation_scheduler=True,
+ )
+ host = ServiceHost(application)
+ from core.private_storage import atomic_write_private_json
+
+ atomic_write_private_json(
+ files.directory / "state-layout.json",
+ {
+ "schemaVersion": 1,
+ "database": str(files.database),
+ "sessions": str(application.session_store.root),
+ "config": str(application.llm.config_store.path),
+ "credentials": str(application.credentials.path),
+ "revisions": str(
+ application.credentials.path.parent / "provider_revisions"
+ ),
+ },
+ )
+ await asyncio.to_thread(host.start)
+ control = ControlServer(host, record, token)
+ runner = web.AppRunner(
+ control.application(), access_log=None, shutdown_timeout=10
+ )
+ await runner.setup()
+ await web.SockSite(runner, listener).start()
+
+ def stop() -> None:
+ control.interrupt.set()
+ control.stopped.set()
+
+ for sig in (signal.SIGINT, signal.SIGTERM):
+ try:
+ loop.add_signal_handler(sig, stop)
+ except NotImplementedError:
+ # Windows asyncio uses the process's ordinary signal path.
+ continue
+ installed_signals.append(sig)
+ files.publish(record, token)
+ logger.info("Service ready at %s (pid %d)", record.url, record.pid)
+ await control.stopped.wait()
+ finally:
+ if control is not None:
+ control.interrupt.set()
+ control.phase = "stopping"
+ try:
+ if runner is not None:
+ await runner.cleanup()
+ finally:
+ try:
+ if host is not None:
+ await asyncio.to_thread(host.close)
+ files.clear()
+ lease.close()
+ finally:
+ listener.close()
+ for sig in installed_signals:
+ loop.remove_signal_handler(sig)
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Run the DeepCode service in the foreground"
+ )
+ parser.add_argument("--database", type=Path, default=None)
+ parser.add_argument("--port", type=int, default=3081)
+ parser.add_argument(
+ "--foreground",
+ action="store_true",
+ help="Keep the service attached to this terminal (default)",
+ )
+ parser.add_argument("--log-file", action="store_true", help=argparse.SUPPRESS)
+ args = parser.parse_args(argv)
+ if not 0 <= args.port <= 65535:
+ parser.error("port must be between 0 and 65535")
+ files = ServiceFiles(args.database or default_database_path())
+ if args.log_file:
+ ensure_private_directory(files.directory)
+ handler = _PrivateRotatingLog(
+ files.log, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
+ )
+ else:
+ handler = None
+ setup_logging(
+ LoggerConfig(
+ level=os.environ.get("DEEPCODE_LOG_LEVEL", "INFO"), transports=["console"]
+ ),
+ force=True,
+ console_sink=handler,
+ )
+ try:
+ asyncio.run(serve(files, args.port))
+ except KeyboardInterrupt:
+ return 130
+ except Exception as exc:
+ logger.exception("DeepCode service failed (%s): %s", type(exc).__name__, exc)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/app_server/service_client.py b/app_server/service_client.py
new file mode 100644
index 000000000..064f83c58
--- /dev/null
+++ b/app_server/service_client.py
@@ -0,0 +1,100 @@
+"""Authenticated local management client; never owns an application runtime."""
+
+from __future__ import annotations
+
+import hmac
+import secrets
+from typing import Any
+
+import httpx
+
+from app_server.service_state import ServiceFiles, identity_proof
+
+
+class ServiceUnavailable(RuntimeError):
+ pass
+
+
+class ServiceOperationError(RuntimeError):
+ pass
+
+
+class ServiceClient:
+ def __init__(self, files: ServiceFiles) -> None:
+ self.files = files
+
+ def call(
+ self,
+ method: str,
+ params: dict[str, Any] | None = None,
+ *,
+ timeout: float = 5.0,
+ instance_id: str | None = None,
+ ) -> dict[str, Any]:
+ if not self.files.running():
+ raise ServiceUnavailable("DeepCode service is not running")
+ discovered = self.files.read()
+ if discovered is None:
+ raise ServiceUnavailable("DeepCode service is starting or stopping")
+ record, token = discovered
+ if instance_id is not None and record.instance_id != instance_id:
+ raise ServiceUnavailable("Service instance changed before the operation")
+ challenge = secrets.token_hex(16)
+ try:
+ # The lock and challenge prevent sending a stale token to another
+ # process that has taken the old port. Ignore ambient HTTP proxies.
+ with httpx.Client(
+ base_url=record.url, trust_env=False, timeout=timeout
+ ) as client:
+ response = client.get(
+ "/control/identity", params={"challenge": challenge}
+ )
+ response.raise_for_status()
+ identity = response.json()
+ proof = identity.get("proof") if isinstance(identity, dict) else None
+ if not isinstance(proof, str) or not hmac.compare_digest(
+ proof.encode(),
+ identity_proof(token, record.instance_id, challenge).encode(),
+ ):
+ raise ServiceUnavailable(
+ "Service identity does not match its private record"
+ )
+ response = client.post(
+ "/control/rpc",
+ headers={
+ "Authorization": f"Bearer {token}",
+ "X-DeepCode-Instance": record.instance_id,
+ },
+ json={
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": method,
+ "params": params or {},
+ },
+ )
+ response.raise_for_status()
+ value = response.json()
+ except (httpx.HTTPError, ValueError) as exc:
+ # Keep diagnostics actionable without echoing request headers/tokens.
+ detail = (
+ f"HTTP {exc.response.status_code}"
+ if isinstance(exc, httpx.HTTPStatusError)
+ else type(exc).__name__
+ )
+ raise ServiceUnavailable(
+ f"Cannot communicate with the local DeepCode service ({detail}); check service logs"
+ ) from exc
+ if not isinstance(value, dict) or value.get("id") != 1:
+ raise ServiceUnavailable("Invalid service response")
+ if "error" in value:
+ error = value["error"]
+ if not isinstance(error, dict) or not isinstance(error.get("message"), str):
+ raise ServiceUnavailable("Invalid service error response")
+ raise ServiceOperationError(error["message"])
+ result = value.get("result")
+ if (
+ not isinstance(result, dict)
+ or result.get("instanceId") != record.instance_id
+ ):
+ raise ServiceUnavailable("Service instance changed during the request")
+ return result
diff --git a/app_server/service_state.py b/app_server/service_state.py
new file mode 100644
index 000000000..ba6458c51
--- /dev/null
+++ b/app_server/service_state.py
@@ -0,0 +1,205 @@
+"""Private discovery records and the managed service's OS-backed lifetime lease."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import os
+import secrets
+import sys
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+from core.file_lock import FileLease, exclusive_file_lock
+from core.private_storage import open_existing_private_file, open_private_file
+from core.version import __version__
+
+SERVICE_PROTOCOL_VERSION = 1
+SERVICE_WORKING_DIRECTORY = Path(__file__).resolve().parents[1]
+
+
+def service_command(files: ServiceFiles, port: int) -> list[str]:
+ # Keep the venv executable path; resolving its symlink loses the venv.
+ if getattr(sys, "frozen", False):
+ from app_server.runtime_install import pinned_service_executable
+
+ launcher = [str(pinned_service_executable()), "--serve"]
+ else:
+ launcher = [os.path.abspath(sys.executable), "-m", "app_server.service"]
+ return [
+ *launcher,
+ "--database",
+ str(files.database),
+ "--port",
+ str(port),
+ "--log-file",
+ ]
+
+
+def identity_proof(token: str, instance_id: str, challenge: str) -> str:
+ return hmac.new(
+ token.encode(), f"{instance_id}:{challenge}".encode(), hashlib.sha256
+ ).hexdigest()
+
+
+@dataclass(frozen=True)
+class ServiceRecord:
+ instance_id: str
+ database: str
+ pid: int
+ port: int
+ version: str = __version__
+ protocol_version: int = SERVICE_PROTOCOL_VERSION
+
+ @property
+ def url(self) -> str:
+ return f"http://127.0.0.1:{self.port}"
+
+
+class ServiceFiles:
+ def __init__(self, database: Path) -> None:
+ self.database = database.expanduser().resolve()
+ self.directory = self.database.with_name(self.database.name + ".service")
+ self.lock = self.directory / "instance.lock"
+ self._discovery_lock = self.directory / "discovery.lock"
+ self.record = self.directory / "instance.json"
+ self.token = self.directory / "token"
+ self.log = self.directory / "service.log"
+
+ def acquire(self) -> FileLease | None:
+ return FileLease.acquire(self.lock, shared=False, blocking=False)
+
+ def running(self) -> bool:
+ lease = self.acquire()
+ if lease is None:
+ return True
+ lease.close()
+ return False
+
+ def read(self) -> tuple[ServiceRecord, str] | None:
+ # The lifetime lease identifies the owner; it does not exclude readers.
+ # Keep the record/token pair consistent and prevent Windows readers from
+ # opening files while the owner deletes or replaces them.
+ with exclusive_file_lock(self._discovery_lock):
+ try:
+ value = json.loads(_read(self.record))
+ token = _read(self.token).strip()
+ except FileNotFoundError:
+ return None
+ if not isinstance(value, dict):
+ raise ValueError("Invalid service discovery record")
+ try:
+ record = ServiceRecord(**value)
+ except TypeError as exc:
+ raise ValueError("Invalid service discovery record") from exc
+ if (
+ record.database != str(self.database)
+ or type(record.pid) is not int
+ or record.pid < 1
+ or type(record.port) is not int
+ or not 1 <= record.port <= 65535
+ or type(record.protocol_version) is not int
+ or record.protocol_version != SERVICE_PROTOCOL_VERSION
+ or not isinstance(record.version, str)
+ or not isinstance(record.instance_id, str)
+ or len(record.instance_id) != 32
+ or any(char not in "0123456789abcdef" for char in record.instance_id)
+ or len(token) != 64
+ or any(char not in "0123456789abcdef" for char in token)
+ ):
+ raise ValueError("Invalid or incompatible service discovery record")
+ return record, token
+
+ def publish(self, record: ServiceRecord, token: str) -> None:
+ with exclusive_file_lock(self._discovery_lock):
+ _write(self.token, token)
+ _write(self.record, json.dumps(asdict(record)))
+
+ def clear(self) -> None:
+ """Only the exclusive lease holder may remove these records."""
+ with exclusive_file_lock(self._discovery_lock):
+ self.record.unlink(missing_ok=True)
+ self.token.unlink(missing_ok=True)
+
+
+def _read(path: Path) -> str:
+ with os.fdopen(open_existing_private_file(path), "r", encoding="utf-8") as stream:
+ data = stream.read(16_385)
+ if len(data) > 16_384:
+ raise ValueError("Service discovery record is too large")
+ return data
+
+
+def _write(path: Path, content: str) -> None:
+ temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}")
+ try:
+ fd = open_private_file(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
+ stream.write(content)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def service_command_port(arguments: list[str], database: Path) -> int:
+ """Validate either source or frozen launch syntax without executing it."""
+ if not isinstance(arguments, list) or not all(
+ isinstance(value, str) for value in arguments
+ ):
+ raise ValueError("Invalid service arguments")
+ if arguments[1:3] == ["-m", "app_server.service"]:
+ tail = arguments[3:]
+ elif arguments[1:2] == ["--serve"]:
+ tail = arguments[2:]
+ else:
+ raise ValueError("Unsupported service launcher")
+ if (
+ len(tail) != 5
+ or tail[:2] != ["--database", str(database)]
+ or tail[2] != "--port"
+ or tail[4] != "--log-file"
+ or not tail[3].isdigit()
+ or not 0 <= int(tail[3]) <= 65535
+ or not Path(arguments[0]).is_absolute()
+ ):
+ raise ValueError("Invalid service launch identity")
+ return int(tail[3])
+
+
+def service_working_directory(arguments: list[str]) -> Path:
+ return (
+ Path(arguments[0]).parent
+ if arguments[1:2] == ["--serve"]
+ else SERVICE_WORKING_DIRECTORY
+ )
+
+
+def service_environment(path: str | None = None) -> dict[str, str]:
+ """Capture only the deliberate launch environment, never shell credentials."""
+ from core.config import deepcode_home
+
+ environment = {
+ "DEEPCODE_HOME": str(deepcode_home()),
+ "PATH": path if path is not None else os.environ.get("PATH", os.defpath),
+ }
+ if os.environ.get("DEEPCODE_SESSIONS_DIR"):
+ environment["DEEPCODE_SESSIONS_DIR"] = str(
+ Path(os.environ["DEEPCODE_SESSIONS_DIR"]).expanduser().resolve()
+ )
+ return environment
+
+
+def shell_only_variables() -> list[str]:
+ from core.providers.registry import PROVIDERS
+
+ names = {provider.env_key for provider in PROVIDERS if provider.env_key}
+ names.update(
+ key
+ for key in os.environ
+ if key.endswith("_API_KEY")
+ or key.lower() in {"http_proxy", "https_proxy", "all_proxy"}
+ )
+ return sorted(name for name in names if os.environ.get(name))
diff --git a/app_server/state_backup.py b/app_server/state_backup.py
new file mode 100644
index 000000000..e267637e1
--- /dev/null
+++ b/app_server/state_backup.py
@@ -0,0 +1,446 @@
+"""Offline, verifiable snapshots of the database and canonical runtime state.
+
+Locks reuse existing application, Session and credential mutation boundaries.
+A restore journal blocks application startup until an interrupted restore is
+resumed. Project working trees and installed executables are not restored.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import sqlite3
+import tempfile
+from contextlib import ExitStack, closing, contextmanager
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+
+from app_server.service_state import ServiceFiles
+from core.config import deepcode_home, home_config_path
+from core.file_lock import FileLease
+from core.persistence.database import Database
+from core.private_storage import (
+ atomic_write_private_json,
+ ensure_private_directory,
+ open_existing_private_file,
+ open_private_file,
+)
+from core.version import __version__
+
+SESSION_INTERNAL = {".locks", ".running", ".activity", ".store.lock"}
+
+
+@dataclass(frozen=True)
+class StatePaths:
+ database: Path
+ sessions: Path
+ config: Path
+ credentials: Path
+ revisions: Path
+
+ @classmethod
+ def current(cls, files: ServiceFiles, *, sessions: Path | None = None):
+ layout = files.directory / "state-layout.json"
+ if layout.exists():
+ with os.fdopen(
+ open_existing_private_file(layout), "r", encoding="utf-8"
+ ) as stream:
+ value = json.loads(stream.read(16385))
+ if (
+ not isinstance(value, dict)
+ or value.get("schemaVersion") != 1
+ or value.get("database") != str(files.database)
+ ):
+ raise ValueError("Invalid saved service data layout")
+ fields = {
+ name: Path(value[name])
+ for name in (
+ "database",
+ "sessions",
+ "config",
+ "credentials",
+ "revisions",
+ )
+ }
+ if not all(path.is_absolute() for path in fields.values()):
+ raise ValueError("Service data paths must be absolute")
+ if (
+ sessions is not None
+ and sessions.expanduser().resolve() != fields["sessions"]
+ ):
+ raise ValueError(
+ "The requested Session directory differs from the recorded service layout"
+ )
+ return cls(**fields)
+ home = deepcode_home().resolve()
+ return cls(
+ files.database,
+ (
+ sessions
+ or Path(
+ os.environ.get("DEEPCODE_SESSIONS_DIR") or str(home / "sessions")
+ )
+ )
+ .expanduser()
+ .resolve(),
+ home_config_path().resolve(),
+ home / "credentials.json",
+ home / "provider_revisions",
+ )
+
+ def targets(self) -> dict[str, Path]:
+ return {
+ "database.sqlite3": self.database,
+ "sessions": self.sessions,
+ "config.json": self.config,
+ "credentials.json": self.credentials,
+ "revisions": self.revisions,
+ "mcp-credentials.json": self.credentials.parent / "auth" / "mcp.json",
+ }
+
+
+def _excluded(relative: Path) -> bool:
+ parts = relative.parts
+ if parts[:1] == ("sessions",) and len(parts) > 1:
+ return (
+ parts[1] in SESSION_INTERNAL
+ or parts[1] == "index.db"
+ or parts[1].startswith("index.db-")
+ )
+ return parts == ("revisions", "write.lock")
+
+
+def _files(root: Path, *, prefix=Path()):
+ if root.is_symlink():
+ raise ValueError("State snapshots do not follow symlinks")
+ if not root.exists():
+ return
+ if root.is_file():
+ yield prefix, root
+ return
+ if not root.is_dir():
+ raise ValueError("State snapshots require regular files and directories")
+ for child in sorted(root.iterdir()):
+ relative = prefix / child.name
+ if not _excluded(relative):
+ yield from _files(child, prefix=relative)
+
+
+def _digest(path: Path) -> dict:
+ digest = hashlib.sha256()
+ size = 0
+ with os.fdopen(open_existing_private_file(path), "rb") as stream:
+ while chunk := stream.read(1024 * 1024):
+ digest.update(chunk)
+ size += len(chunk)
+ return {"bytes": size, "sha256": digest.hexdigest()}
+
+
+def _copy_file(source: Path, target: Path) -> None:
+ with os.fdopen(open_existing_private_file(source), "rb") as reader:
+ with os.fdopen(
+ open_private_file(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL), "wb"
+ ) as writer:
+ shutil.copyfileobj(reader, writer, length=1024 * 1024)
+ writer.flush()
+ os.fsync(writer.fileno())
+
+
+def _sync_directory(directory: Path):
+ if os.name != "nt":
+ fd = os.open(directory, os.O_RDONLY)
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+
+
+def _sync_tree(directory: Path):
+ for child in directory.iterdir():
+ if child.is_dir() and not child.is_symlink():
+ _sync_tree(child)
+ _sync_directory(directory)
+
+
+@contextmanager
+def _offline(paths: StatePaths, *, extra_sessions=()):
+ files = ServiceFiles(paths.database)
+ with ExitStack() as stack:
+
+ def lock(path):
+ lease = FileLease.acquire(path, shared=False, blocking=False)
+ if lease is None:
+ raise ValueError(
+ "State is in use. Stop DeepCode services, CLI/TUI and other writers before backing up or restoring."
+ )
+ stack.enter_context(lease)
+
+ lock(files.directory / "management.lock")
+ lock(files.lock)
+ lock(paths.database.with_name(paths.database.name + ".application.lock"))
+ lock(paths.database.with_name(paths.database.name + ".migration.lock"))
+ lock(paths.config.with_suffix(paths.config.suffix + ".lock"))
+ lock(paths.credentials.with_suffix(paths.credentials.suffix + ".lock"))
+ mcp_credentials = paths.targets()["mcp-credentials.json"]
+ lock(mcp_credentials.with_suffix(mcp_credentials.suffix + ".lock"))
+ lock(paths.revisions / "write.lock")
+ lock(paths.sessions / ".store.lock")
+ ids = set(extra_sessions) | {
+ item.name
+ for item in paths.sessions.iterdir()
+ if item.is_dir() and not item.name.startswith(".")
+ }
+ for identity in sorted(ids):
+ if Path(identity).name != identity or identity in {"", ".", ".."}:
+ raise ValueError("Invalid Session identity in snapshot")
+ for directory in (".activity", ".running", ".locks"):
+ lock(paths.sessions / directory / f"{identity}.lock")
+ yield
+
+
+def _snapshot(
+ paths: StatePaths, destination: Path, *, require_idle: bool = True
+) -> dict:
+ if destination.exists():
+ raise ValueError("Snapshot destination already exists")
+ for name, source in paths.targets().items():
+ if destination == source or (
+ name in {"sessions", "revisions"} and destination.is_relative_to(source)
+ ):
+ raise ValueError("Snapshot destination overlaps runtime data")
+ ensure_private_directory(destination.parent)
+ staging = Path(tempfile.mkdtemp(prefix=".snapshot-", dir=destination.parent))
+ try:
+ present = []
+ for name, source in paths.targets().items():
+ if not source.exists():
+ continue
+ present.append(name)
+ if name == "database.sqlite3":
+ target = staging / name
+ os.close(
+ open_private_file(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
+ )
+ with (
+ closing(
+ sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)
+ ) as reader,
+ closing(sqlite3.connect(target)) as writer,
+ ):
+ if require_idle:
+ _require_idle_database(reader)
+ reader.backup(writer)
+ writer.execute("PRAGMA journal_mode=DELETE")
+ if writer.execute("PRAGMA quick_check").fetchone()[0] != "ok":
+ raise ValueError("Snapshot database integrity check failed")
+ with target.open("rb+") as stream:
+ os.fsync(stream.fileno())
+ else:
+ for relative, original in _files(source, prefix=Path(name)):
+ _copy_file(original, staging / relative)
+ inventory = {str(relative): _digest(path) for relative, path in _files(staging)}
+ manifest = {
+ "schemaVersion": 1,
+ "runtimeVersion": __version__,
+ "createdAt": datetime.now(UTC).isoformat(),
+ "paths": {name: str(path) for name, path in paths.targets().items()},
+ "present": present,
+ "files": inventory,
+ }
+ atomic_write_private_json(staging / "manifest.json", manifest)
+ _sync_tree(staging)
+ os.rename(staging, destination)
+ _sync_directory(destination.parent)
+ return {
+ "snapshot": str(destination),
+ "fileCount": len(inventory),
+ "runtimeVersion": __version__,
+ "paths": manifest["paths"],
+ }
+ finally:
+ if staging.exists():
+ shutil.rmtree(staging)
+
+
+def create_snapshot(paths: StatePaths, destination: Path) -> dict:
+ with _offline(paths):
+ if Database(paths.database).restore_marker.exists():
+ raise ValueError(
+ "Resume the pending restore before making another snapshot"
+ )
+ return _snapshot(paths, destination.expanduser().absolute())
+
+
+def _require_idle_database(connection):
+ if (
+ connection.execute(
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='turns'"
+ ).fetchone()
+ and connection.execute(
+ "SELECT 1 FROM turns WHERE status IN ('queued', 'running', 'waiting_approval') LIMIT 1"
+ ).fetchone()
+ ):
+ raise ValueError(
+ "Runtime snapshots require all Turns to be settled. Drain or explicitly cancel pending work first."
+ )
+
+
+def _manifest(snapshot: Path, paths: StatePaths) -> dict:
+ with os.fdopen(
+ open_existing_private_file(snapshot / "manifest.json"), "r", encoding="utf-8"
+ ) as stream:
+ raw = stream.read(8 * 1024 * 1024 + 1)
+ if len(raw) > 8 * 1024 * 1024:
+ raise ValueError("Snapshot manifest exceeds 8 MiB")
+ value = json.loads(raw)
+ if (
+ not isinstance(value, dict)
+ or value.get("schemaVersion") != 1
+ or value.get("paths")
+ != {name: str(path) for name, path in paths.targets().items()}
+ ):
+ raise ValueError(
+ "Snapshot format or original data locations do not match this service"
+ )
+ expected = value.get("files")
+ if (
+ not isinstance(expected, dict)
+ or not isinstance(value.get("present"), list)
+ or set(value["present"]) - paths.targets().keys()
+ ):
+ raise ValueError("Invalid snapshot manifest")
+ actual = {
+ str(relative): _digest(path)
+ for relative, path in _files(snapshot)
+ if str(relative) != "manifest.json"
+ }
+ if actual != expected:
+ raise ValueError("Snapshot contents failed checksum verification")
+ for relative in expected:
+ path = Path(relative)
+ if (
+ path.is_absolute()
+ or ".." in path.parts
+ or not path.parts
+ or path.parts[0] not in paths.targets()
+ ):
+ raise ValueError("Snapshot contains an invalid destination")
+ if path.parts[0] not in {"sessions", "revisions"} and len(path.parts) != 1:
+ raise ValueError("Snapshot file destination is invalid")
+ if "database.sqlite3" in expected:
+ with closing(
+ sqlite3.connect(
+ (snapshot / "database.sqlite3").as_uri() + "?mode=ro&immutable=1",
+ uri=True,
+ )
+ ) as connection:
+ _require_idle_database(connection)
+ return value
+
+
+def _install_file(source: Path, target: Path):
+ temporary = target.with_name("." + target.name + ".restore-new")
+ # A failed earlier attempt may have left only its staging file.
+ temporary.unlink(missing_ok=True)
+ try:
+ _copy_file(source, temporary)
+ os.replace(temporary, target)
+ _sync_directory(target.parent)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def restore_snapshot(paths: StatePaths, snapshot: Path, *, replace_data: bool) -> dict:
+ if not replace_data:
+ raise ValueError(
+ "Restoring replaces runtime data after the snapshot. Pass --replace-data explicitly."
+ )
+ snapshot = snapshot.expanduser().absolute()
+ if any(snapshot.is_relative_to(root) for root in (paths.sessions, paths.revisions)):
+ raise ValueError(
+ "The snapshot must be outside the runtime directories being restored"
+ )
+ manifest = _manifest(snapshot, paths)
+ extra = {
+ Path(name).parts[1]
+ for name in manifest["files"]
+ if name.startswith("sessions/")
+ and len(Path(name).parts) > 2
+ and not Path(name).parts[1].startswith(".")
+ }
+ marker = Database(paths.database).restore_marker
+ with _offline(paths, extra_sessions=extra):
+ # Recheck after locking; nothing has been modified yet.
+ manifest = _manifest(snapshot, paths)
+ identity = _digest(snapshot / "manifest.json")["sha256"]
+ if marker.exists():
+ with os.fdopen(
+ open_existing_private_file(marker), "r", encoding="utf-8"
+ ) as stream:
+ journal = json.load(stream)
+ if (
+ journal.get("snapshot") != str(snapshot)
+ or journal.get("manifestHash") != identity
+ ):
+ raise ValueError(
+ "A different restore is pending. Resume its original snapshot first."
+ )
+ else:
+ before = (
+ paths.database.parent
+ / "backups"
+ / ("before-restore-" + datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f"))
+ )
+ _snapshot(paths, before, require_idle=False)
+ journal = {
+ "schemaVersion": 1,
+ "snapshot": str(snapshot),
+ "manifestHash": identity,
+ "beforeRestore": str(before),
+ }
+ atomic_write_private_json(marker, journal)
+ wanted = set(manifest["files"])
+ for name, target in paths.targets().items():
+ if name in {"sessions", "revisions"}:
+ # Preserve lock inodes, preventing a competing process from
+ # acquiring a newly created replacement lock during restore.
+ for relative, current in list(_files(target, prefix=Path(name))):
+ if str(relative) not in wanted:
+ current.unlink()
+ elif name not in manifest["present"]:
+ target.unlink(missing_ok=True)
+ for relative in sorted(wanted):
+ parts = Path(relative).parts
+ target = paths.targets()[parts[0]].joinpath(*parts[1:])
+ _install_file(snapshot / relative, target)
+ # Disposable indexes/WAL must not replay changes from the replaced DB.
+ for path in (paths.database, paths.sessions / "index.db"):
+ for suffix in ("-wal", "-shm", "-journal"):
+ Path(str(path) + suffix).unlink(missing_ok=True)
+ (paths.sessions / "index.db").unlink(missing_ok=True)
+ for root in (paths.sessions, paths.revisions):
+ _sync_tree(root)
+ # Pending external login flows must never become valid again merely
+ # because a backup restored their old generation values.
+ if paths.credentials.exists():
+ data = json.loads(paths.credentials.read_text())
+ import secrets
+
+ data["loginGenerations"] = {
+ key: secrets.token_hex(24) for key in data.get("loginGenerations", {})
+ }
+ atomic_write_private_json(paths.credentials, data)
+ atomic_write_private_json(
+ Database(paths.database).restore_recovery_marker,
+ {"schemaVersion": 1, "snapshot": str(snapshot)},
+ )
+ marker.unlink()
+ _sync_directory(marker.parent)
+ return {
+ "restored": str(snapshot),
+ "beforeRestore": journal["beforeRestore"],
+ "phase": "stopped",
+ }
diff --git a/app_server/stdio_relay.py b/app_server/stdio_relay.py
new file mode 100644
index 000000000..4e6b853ca
--- /dev/null
+++ b/app_server/stdio_relay.py
@@ -0,0 +1,218 @@
+"""Native stdio attachment to a shared service; EOF releases only this client."""
+
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import queue
+import os
+import select
+import threading
+from typing import BinaryIO
+
+from app_server.errors import NotInitialized, RpcError
+from app_server.native_client import NativeRpcClient
+from app_server.protocol.codec import (
+ DEFAULT_MAX_MESSAGE_BYTES,
+ decode_request,
+ encode_message,
+)
+from app_server.protocol.models import Response
+from app_server.service_state import ServiceFiles
+
+
+def serve_relay(files: ServiceFiles, source: BinaryIO, sink: BinaryIO) -> int:
+ """Bounded pipes keep slow/closed Desktop clients from retaining the service."""
+ incoming: queue.Queue[bytes | None] = queue.Queue(maxsize=32)
+ outgoing: queue.Queue[bytes | None] = queue.Queue(maxsize=256)
+ output_lock = threading.Lock()
+ output_bytes = 0
+ stopped = threading.Event()
+ failed = threading.Event()
+ loop = asyncio.new_event_loop()
+ thread = threading.Thread(
+ target=loop.run_forever, name="deepcode-native-rpc", daemon=True
+ )
+ thread.start()
+ client = NativeRpcClient(files, notify=lambda message: emit(message))
+
+ def emit(message: dict) -> None:
+ nonlocal output_bytes
+ try:
+ frame = encode_message(message)
+ if len(frame) > DEFAULT_MAX_MESSAGE_BYTES:
+ raise ValueError("Response exceeds the message limit")
+ with output_lock:
+ if output_bytes + len(frame) > 8 * 1024 * 1024:
+ raise queue.Full
+ outgoing.put_nowait(frame)
+ output_bytes += len(frame)
+ except (queue.Full, ValueError):
+ failed.set()
+
+ def read() -> None:
+ try:
+ for raw in _input_frames(source, stopped):
+ if len(raw) > DEFAULT_MAX_MESSAGE_BYTES:
+ failed.set()
+ return
+ while not stopped.is_set():
+ try:
+ incoming.put(raw or None, timeout=0.1)
+ break
+ except queue.Full:
+ continue
+ if not raw:
+ return
+ except (OSError, ValueError):
+ failed.set()
+
+ def write() -> None:
+ nonlocal output_bytes
+ try:
+ while True:
+ frame = outgoing.get()
+ if frame is None:
+ return
+ sink.write(frame)
+ sink.flush()
+ with output_lock:
+ output_bytes -= len(frame)
+ except (OSError, ValueError):
+ failed.set()
+
+ reader = threading.Thread(target=read, name="deepcode-native-stdin", daemon=True)
+ writer = threading.Thread(target=write, name="deepcode-native-stdout", daemon=True)
+ reader.start()
+ writer.start()
+
+ def run(coroutine):
+ future = asyncio.run_coroutine_threadsafe(coroutine, loop)
+ try:
+ return future.result(timeout=40)
+ except concurrent.futures.TimeoutError:
+ future.cancel()
+ raise RpcError(
+ -32000, "Service connection timed out", stable_code="RESULT_UNKNOWN"
+ ) from None
+
+ try:
+ initialized = False
+ while not failed.is_set():
+ if initialized and client.closed.is_set():
+ # EOF tells the native bridge to offer Reconnect. Never start a
+ # replacement application after a network failure.
+ break
+ try:
+ raw = incoming.get(timeout=0.1)
+ except queue.Empty:
+ continue
+ if raw is None:
+ break
+ request = None
+ try:
+ request = decode_request(raw)
+ if not initialized:
+ if request.method != "initialize":
+ raise NotInitialized()
+ result = run(client.connect(request.params, start=True))
+ initialized = True
+ elif request.method == "shutdown":
+ result = {"accepted": True}
+ elif request.method == "service/status":
+ from app_server.service_client import ServiceClient
+
+ if request.params:
+ raise RpcError(
+ -32602,
+ "Unexpected parameters",
+ stable_code="INVALID_REQUEST",
+ )
+ result = ServiceClient(files).call("status")
+ elif request.method == "service/stop":
+ from cli.service_cli import stop_service
+
+ if request.params:
+ raise RpcError(
+ -32602,
+ "Unexpected parameters",
+ stable_code="INVALID_REQUEST",
+ )
+ # Native management only, bounded drain; no implicit cancel.
+ result = stop_service(files, timeout=10, cancel_running=False)
+ else:
+ result = run(client.request(request.method, request.params))
+ if request.has_id:
+ emit(Response(request.id, result=result).to_dict())
+ if request.method == "shutdown":
+ break
+ except RpcError as exc:
+ if request is None or request.has_id:
+ emit(
+ Response(
+ request.id if request else None, error=exc.payload()
+ ).to_dict()
+ )
+ except (OSError, RuntimeError, ValueError) as exc:
+ error = RpcError(-32000, str(exc), stable_code="SERVICE_UNAVAILABLE")
+ emit(
+ Response(
+ request.id if request else None, error=error.payload()
+ ).to_dict()
+ )
+ break
+ return 1 if failed.is_set() else 0
+ finally:
+ stopped.set()
+ try:
+ asyncio.run_coroutine_threadsafe(client.close(), loop).result(timeout=5)
+ except (concurrent.futures.TimeoutError, RuntimeError):
+ pass
+ loop.call_soon_threadsafe(loop.stop)
+ thread.join(timeout=1)
+ if not thread.is_alive():
+ loop.close()
+ try:
+ outgoing.put(None, timeout=1)
+ except queue.Full:
+ pass
+ writer.join(timeout=1)
+ reader.join(timeout=1)
+
+
+def _input_frames(source: BinaryIO, stopped: threading.Event):
+ """Frame raw pipe reads without holding Python's buffered-stdin lock.
+
+ A shutdown request may arrive while the parent keeps stdin open. A daemon
+ blocked in BufferedReader.readline would abort Python during finalization.
+ POSIX readiness polling also lets the reader exit without waiting for EOF.
+ Windows raw ReadFile may remain blocked until process exit, but owns no
+ Python buffered stream lock and does not prevent the relay from exiting.
+ """
+ try:
+ descriptor = source.fileno()
+ except (AttributeError, OSError, ValueError):
+ while not stopped.is_set():
+ frame = source.readline(DEFAULT_MAX_MESSAGE_BYTES + 1)
+ yield frame
+ if not frame:
+ return
+ return
+ pending = bytearray()
+ while not stopped.is_set():
+ if os.name != "nt" and not select.select([descriptor], [], [], 0.1)[0]:
+ continue
+ chunk = os.read(
+ descriptor, min(65536, DEFAULT_MAX_MESSAGE_BYTES + 1 - len(pending))
+ )
+ if not chunk:
+ if pending:
+ yield bytes(pending)
+ yield b""
+ return
+ pending.extend(chunk)
+ while (end := pending.find(b"\n")) >= 0:
+ yield bytes(pending[: end + 1])
+ del pending[: end + 1]
+ if len(pending) > DEFAULT_MAX_MESSAGE_BYTES:
+ raise ValueError("Input frame exceeds the message limit")
diff --git a/app_server/systemd_user.py b/app_server/systemd_user.py
new file mode 100644
index 000000000..c0b66d95f
--- /dev/null
+++ b/app_server/systemd_user.py
@@ -0,0 +1,280 @@
+"""Linux user-systemd supervision for the existing service process."""
+
+from __future__ import annotations
+
+import configparser
+import hashlib
+import os
+import shlex
+import subprocess
+from pathlib import Path
+
+from app_server.service_client import ServiceOperationError
+from app_server.service_state import (
+ ServiceFiles,
+ service_command,
+ service_command_port,
+ service_environment,
+ service_working_directory,
+ shell_only_variables,
+)
+from core.config import deepcode_home
+from core.private_storage import open_existing_private_file, open_private_file
+
+
+def _specifier(value: str) -> str:
+ if any(ord(char) < 32 for char in value):
+ raise ValueError(
+ "Service paths and environment cannot contain control characters"
+ )
+ return value.replace("%", "%%")
+
+
+def _quote(value: str) -> str:
+ return '"' + _specifier(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+
+class SystemdUserService:
+ name = "systemd user service"
+
+ def __init__(self, files: ServiceFiles, *, directory: Path | None = None):
+ self.files = files
+ identity = hashlib.sha256(str(files.database).encode()).hexdigest()[:16]
+ self.label = f"deepcode-{identity}.service"
+ config = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
+ if not config.is_absolute():
+ raise ValueError("XDG_CONFIG_HOME must be an absolute path")
+ self.path = (directory or config / "systemd/user") / self.label
+
+ @staticmethod
+ def _run(*args, timeout=15):
+ try:
+ return subprocess.run(
+ ["systemctl", "--user", *args],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise ServiceOperationError(
+ "Cannot communicate with the user systemd manager; inspect service doctor"
+ ) from exc
+
+ def available(self):
+ try:
+ return self._run("show", "--property=Version", "--value").returncode == 0
+ except ServiceOperationError:
+ return False
+
+ def job(self):
+ if not self.path.exists() or not self.available():
+ return {"loaded": False, "pid": None}
+ result = self._run(
+ "show", self.label, "--property=ActiveState,SubState,MainPID", "--no-pager"
+ )
+ if result.returncode:
+ raise ServiceOperationError("Cannot inspect the installed systemd service")
+ values = dict(
+ line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
+ )
+ active = values.get("ActiveState") in {
+ "active",
+ "activating",
+ "reloading",
+ "deactivating",
+ }
+ pid = int(values.get("MainPID", "0"))
+ return {"loaded": active, "pid": pid or None}
+
+ def read(self):
+ try:
+ with os.fdopen(
+ open_existing_private_file(self.path), "r", encoding="utf-8"
+ ) as stream:
+ text = stream.read(65537)
+ except FileNotFoundError:
+ return None
+ if len(text) > 65536:
+ raise ServiceOperationError("Systemd service file is too large")
+ parser = configparser.ConfigParser(interpolation=None, strict=True)
+ parser.optionxform = str
+ try:
+ parser.read_string(text)
+ command = parser["Service"]["ExecStart"]
+ if not command.startswith(":"):
+ raise ValueError("Environment substitution must be disabled")
+ arguments = [value.replace("%%", "%") for value in shlex.split(command[1:])]
+ port = service_command_port(arguments, self.files.database)
+ directory = parser["Service"]["WorkingDirectory"].replace("%%", "%")
+ environment = dict(
+ value.replace("%%", "%").split("=", 1)
+ for value in shlex.split(parser["Service"]["Environment"])
+ )
+ if (
+ not Path(directory).is_absolute()
+ or parser["Service"]["Restart"] != "on-failure"
+ or parser["Service"]["KillMode"] != "mixed"
+ or set(environment) - {"DEEPCODE_HOME", "DEEPCODE_SESSIONS_DIR", "PATH"}
+ ):
+ raise ValueError("Unexpected service configuration")
+ value = {
+ "command": arguments,
+ "directory": directory,
+ "environment": environment,
+ "port": port,
+ }
+ if text != self._render(value):
+ raise ValueError(
+ "Service definition differs from its managed configuration"
+ )
+ return value
+ except (configparser.Error, KeyError, ValueError, IndexError) as exc:
+ raise ServiceOperationError(
+ "Systemd service configuration changed; stop and reinstall it"
+ ) from exc
+
+ @staticmethod
+ def _render(value):
+ return "\n".join(
+ [
+ "[Unit]",
+ "Description=DeepCode local service",
+ "StartLimitIntervalSec=120",
+ "StartLimitBurst=5",
+ "",
+ "[Service]",
+ "Type=exec",
+ "ExecStart=:" + " ".join(_quote(arg) for arg in value["command"]),
+ "WorkingDirectory=" + _specifier(value["directory"]),
+ "Environment="
+ + " ".join(
+ _quote(key + "=" + item)
+ for key, item in value["environment"].items()
+ ),
+ "Restart=on-failure",
+ "RestartSec=10",
+ "TimeoutStopSec=35",
+ "KillMode=mixed",
+ "",
+ "[Install]",
+ "WantedBy=default.target",
+ "",
+ ]
+ )
+
+ def install(self, *, port, path=None):
+ command = service_command(self.files, port)
+ service_command_port(command, self.files.database)
+ environment = service_environment(path)
+ value = {
+ "command": command,
+ "directory": str(service_working_directory(command)),
+ "environment": environment,
+ "port": port,
+ }
+ previous = self.read()
+ if previous != value and self.job()["loaded"]:
+ raise ServiceOperationError(
+ "Stop the loaded service before changing its systemd unit"
+ )
+ if previous != value:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = self.path.with_suffix(".tmp")
+ try:
+ fd = open_private_file(temporary, os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
+ stream.write(self._render(value))
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, self.path)
+ finally:
+ temporary.unlink(missing_ok=True)
+ result = self._run("--no-reload", "enable", str(self.path))
+ if result.returncode:
+ raise ServiceOperationError(
+ "Could not enable the user service; inspect service doctor"
+ )
+ if self.available() and self._run("daemon-reload").returncode:
+ raise ServiceOperationError("Systemd could not reload the installed unit")
+ return {"installed": True, "atLogin": True, "path": str(self.path)}
+
+ def start(self, *, port=None):
+ value = self.read()
+ if value is None:
+ raise ServiceOperationError("No user service is installed")
+ if port is not None and port != value["port"]:
+ raise ServiceOperationError(
+ "Requested port differs from the installed service; reinstall it"
+ )
+ if not self.available():
+ raise ServiceOperationError(
+ "No user systemd session is available; use deepcode serve --foreground"
+ )
+ if (
+ not Path(value["command"][0]).is_file()
+ or not Path(value["directory"]).is_dir()
+ ):
+ raise ServiceOperationError(
+ "Installed runtime path is missing; reinstall the service"
+ )
+ self._run("reset-failed", self.label)
+ result = self._run("start", self.label)
+ if result.returncode:
+ raise ServiceOperationError(
+ "Systemd could not start the service; inspect service doctor and journalctl --user"
+ )
+
+ def unload(self):
+ if (
+ self.job()["loaded"]
+ and self._run("stop", self.label, timeout=50).returncode
+ ):
+ raise ServiceOperationError("Systemd could not stop the service")
+
+ def uninstall(self):
+ if self.job()["loaded"]:
+ raise ServiceOperationError("Stop the user service before uninstalling it")
+ value = self.read()
+ if value is not None:
+ if self._run("--no-reload", "disable", self.label).returncode:
+ raise ServiceOperationError("Could not disable the user service")
+ self.path.unlink()
+ if self.available():
+ self._run("daemon-reload")
+ return {"installed": False, "atLogin": False, "path": str(self.path)}
+
+ def doctor(self):
+ available = self.available()
+ checks = [{"name": "userManager", "ok": available}]
+ try:
+ value = self.read()
+ checks.append({"name": "configuration", "ok": value is not None})
+ if value:
+ checks.extend(
+ [
+ {
+ "name": "executable",
+ "ok": Path(value["command"][0]).is_file(),
+ },
+ {
+ "name": "workingDirectory",
+ "ok": Path(value["directory"]).is_dir(),
+ },
+ {
+ "name": "runtimeHome",
+ "ok": value["environment"].get("DEEPCODE_HOME")
+ == str(deepcode_home()),
+ },
+ ]
+ )
+ except ServiceOperationError as exc:
+ checks.append({"name": "configuration", "ok": False, "message": str(exc)})
+ return {
+ "installed": self.path.exists(),
+ "path": str(self.path),
+ "sessionAvailable": available,
+ **self.job(),
+ "checks": checks,
+ "shellOnlyVariables": shell_only_variables(),
+ }
diff --git a/app_server/web_surface.py b/app_server/web_surface.py
new file mode 100644
index 000000000..78a2ba56b
--- /dev/null
+++ b/app_server/web_surface.py
@@ -0,0 +1,279 @@
+"""Same-origin browser assets and bounded, authenticated workspace transfers."""
+
+from __future__ import annotations
+
+import asyncio
+import itertools
+import json
+import os
+import re
+import secrets
+import stat
+from pathlib import Path
+from urllib.parse import quote
+
+from aiohttp import web
+
+from app_server.browser_auth import BrowserAuth
+from core.application.errors import ApplicationError
+from core.private_storage import ensure_private_file
+from core.version import __version__
+
+ASSET_DIRECTORY = Path(__file__).with_name("web_assets")
+MAX_UPLOAD = 10 * 1024 * 1024
+MAX_DOWNLOAD = 32 * 1024 * 1024
+
+
+def read_web_build(assets: Path = ASSET_DIRECTORY) -> dict | None:
+ try:
+ value = json.loads((assets / "web-build.json").read_text())
+ if value.get("version") != __version__ or not isinstance(
+ value.get("buildId"), str
+ ):
+ return None
+ return value
+ except (OSError, ValueError, AttributeError):
+ return None
+
+
+async def _file_io(function, *args):
+ """Finish the current descriptor operation before cancellation closes it."""
+ task = asyncio.create_task(asyncio.to_thread(function, *args))
+ try:
+ return await asyncio.shield(task)
+ except asyncio.CancelledError:
+ await task
+ raise
+
+
+class WebSurface:
+ def __init__(
+ self, application, auth: BrowserAuth, phase, *, assets: Path = ASSET_DIRECTORY
+ ):
+ self.application = application
+ self.auth = auth
+ self.phase = phase
+ self.assets = assets.resolve()
+ self._uploads = asyncio.Semaphore(4)
+ self._upload_lock = asyncio.Lock()
+
+ def build(self) -> dict | None:
+ return read_web_build(self.assets)
+
+ def routes(self):
+ return [
+ web.get("/", self.index),
+ web.get("/index.html", self.index),
+ web.get("/web-build.json", self.manifest),
+ web.get("/assets/{path:.*}", self.asset),
+ web.get("/api/session", self.session),
+ web.post("/api/uploads", self.upload),
+ web.get("/api/download", self.download),
+ ]
+
+ async def index(self, request):
+ if self.build() is None or not (self.assets / "index.html").is_file():
+ return web.Response(
+ status=503,
+ text="DeepCode web assets are missing or incompatible. Reinstall a complete release, or run npm ci and npm run build:web in desktop/ before starting this service.",
+ )
+ return web.FileResponse(self.assets / "index.html")
+
+ async def manifest(self, request):
+ build = self.build()
+ if build is None:
+ raise web.HTTPServiceUnavailable(text="Web assets are unavailable")
+ return web.json_response(build)
+
+ async def asset(self, request):
+ relative = Path(request.match_info["path"])
+ path = (self.assets / "assets" / relative).resolve()
+ if (
+ relative.is_absolute()
+ or ".." in relative.parts
+ or not path.is_relative_to(self.assets / "assets")
+ or not path.is_file()
+ ):
+ raise web.HTTPNotFound()
+ return web.FileResponse(path)
+
+ async def session(self, request):
+ self.auth.require(request)
+ return web.json_response(
+ {
+ "authenticated": True,
+ "phase": self.phase(),
+ "version": __version__,
+ "webBuild": self.build(),
+ }
+ )
+
+ async def upload(self, request):
+ self.auth.require(request)
+ if self.phase() != "ready":
+ raise web.HTTPServiceUnavailable(text="Service is not accepting uploads")
+ if request.content_type != "application/octet-stream":
+ raise web.HTTPUnsupportedMediaType(text="Binary upload body required")
+ if request.content_length is not None and request.content_length > MAX_UPLOAD:
+ raise web.HTTPRequestEntityTooLarge(
+ max_size=MAX_UPLOAD, actual_size=request.content_length
+ )
+ try:
+ context = await asyncio.to_thread(
+ self.application.workspaces.resolve,
+ request.query.get("threadId", ""),
+ require_trusted=True,
+ )
+ except ApplicationError as exc:
+ raise web.HTTPForbidden(text=exc.user_message) from exc
+ # A new root-level file avoids following user-controlled subdirectories
+ # or accepting a browser-local path as a server filesystem path.
+ name = (
+ re.sub(
+ r"[^\w.\-]", "_", Path(request.query.get("name", "attachment")).name
+ )[:100]
+ or "attachment"
+ )
+ filename = f"deepcode-upload-{secrets.token_hex(12)}-{name}"
+ async with self._uploads, self._upload_lock:
+ directory = (
+ os.open(context.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ if os.open in os.supports_dir_fd
+ else None
+ )
+ staging_name = f".{filename}.part"
+ target = (
+ staging_name if directory is not None else context.root / staging_name
+ )
+ destination = filename if directory is not None else context.root / filename
+ descriptor = None
+ completed = False
+ try:
+ existing = await asyncio.to_thread(
+ lambda: sum(
+ path.stat().st_size
+ for path in itertools.chain(
+ context.root.glob("deepcode-upload-*"),
+ context.root.glob(".deepcode-upload-*.part"),
+ )
+ if path.is_file() and not path.is_symlink()
+ )
+ )
+ if existing >= 64 * 1024 * 1024:
+ raise web.HTTPConflict(
+ text="Remove unused uploaded files before adding more (64 MiB workspace upload budget)"
+ )
+ descriptor = os.open(
+ target,
+ os.O_WRONLY
+ | os.O_CREAT
+ | os.O_EXCL
+ | getattr(os, "O_NOFOLLOW", 0)
+ | getattr(os, "O_BINARY", 0),
+ 0o600,
+ dir_fd=directory,
+ )
+ if os.name == "nt":
+ await _file_io(ensure_private_file, context.root / staging_name)
+ count = 0
+ async for chunk in request.content.iter_chunked(64 * 1024):
+ count += len(chunk)
+ if count > MAX_UPLOAD or existing + count > 64 * 1024 * 1024:
+ raise web.HTTPRequestEntityTooLarge(
+ max_size=MAX_UPLOAD, actual_size=count
+ )
+
+ # os.write may be partial even for a regular file.
+ def write_all(data=chunk):
+ view = memoryview(data)
+ while view:
+ written = os.write(descriptor, view)
+ if not written:
+ raise OSError("upload write made no progress")
+ view = view[written:]
+
+ await _file_io(write_all)
+ await _file_io(os.fsync, descriptor)
+ os.close(descriptor)
+ descriptor = None
+ if directory is None:
+ # Windows rename fails if the destination already exists.
+ os.rename(target, destination)
+ else:
+ os.link(
+ target,
+ destination,
+ src_dir_fd=directory,
+ dst_dir_fd=directory,
+ follow_symlinks=False,
+ )
+ os.unlink(target, dir_fd=directory)
+ await _file_io(os.fsync, directory)
+ completed = True
+ return web.json_response(
+ {"path": str(context.root / filename), "name": name, "size": count}
+ )
+ finally:
+ if descriptor is not None:
+ os.close(descriptor)
+ if not completed:
+ try:
+ os.unlink(target, dir_fd=directory)
+ except FileNotFoundError:
+ pass
+ if directory is not None:
+ os.close(directory)
+
+ async def download(self, request):
+ self.auth.require(request)
+ try:
+ context = await asyncio.to_thread(
+ self.application.workspaces.resolve, request.query.get("threadId", "")
+ )
+ path = self.application.workspaces.path(
+ context, request.query.get("path", "")
+ )
+ except ApplicationError as exc:
+ raise web.HTTPForbidden(text=exc.user_message) from exc
+ # Open once; the response uses this descriptor's bytes, not a later path
+ # lookup which could follow a swapped symlink.
+ async with self._uploads:
+ before = path.lstat()
+ if not stat.S_ISREG(before.st_mode):
+ raise web.HTTPBadRequest(text="Download requires a regular file")
+ descriptor = os.open(
+ path,
+ os.O_RDONLY
+ | getattr(os, "O_NOFOLLOW", 0)
+ | getattr(os, "O_NONBLOCK", 0)
+ | getattr(os, "O_BINARY", 0),
+ )
+ try:
+ info = os.fstat(descriptor)
+ if not os.path.samestat(before, info):
+ raise web.HTTPConflict(text="File changed while opening download")
+ if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_DOWNLOAD:
+ raise web.HTTPBadRequest(
+ text="Download requires a regular file up to 32 MiB"
+ )
+ response = web.StreamResponse(
+ headers={
+ "Content-Type": "application/octet-stream",
+ "Content-Length": str(info.st_size),
+ "Content-Disposition": f"attachment; filename*=UTF-8''{quote(path.name, safe='')}",
+ }
+ )
+ await response.prepare(request)
+ remaining = info.st_size
+ while remaining:
+ data = await _file_io(
+ os.read, descriptor, min(64 * 1024, remaining)
+ )
+ if not data:
+ raise ConnectionError("File changed during download")
+ await response.write(data)
+ remaining -= len(data)
+ await response.write_eof()
+ return response
+ finally:
+ os.close(descriptor)
diff --git a/app_server/websocket.py b/app_server/websocket.py
new file mode 100644
index 000000000..66d4daaf9
--- /dev/null
+++ b/app_server/websocket.py
@@ -0,0 +1,329 @@
+"""Bounded aiohttp transport over the same RPC peer used by stdio."""
+
+from __future__ import annotations
+
+import asyncio
+from collections import deque
+from collections.abc import Callable
+from concurrent.futures import ThreadPoolExecutor
+from functools import partial
+import threading
+
+from aiohttp import WSMsgType, web
+
+from app_server.browser_auth import BrowserAuth
+from app_server.errors import RpcError
+from app_server.host import ServiceHost
+from app_server.peer import RpcPeer
+from app_server.protocol.models import Request
+
+
+async def _close_socket(
+ ws: web.WebSocketResponse, *, code: int = 1000, message: bytes = b""
+) -> None:
+ # aiohttp's close timeout covers reading the peer's reply, not all writes.
+ # Bound the whole close so a stalled client cannot hold logout or shutdown.
+ try:
+ await asyncio.wait_for(ws.close(code=code, message=message, drain=False), 3)
+ except TimeoutError:
+ pass
+
+
+class FrameQueue:
+ """A bounded frame queue, including its scheduled wakeups.
+
+ Peer writers never wait for a socket. Overflow disconnects that client so
+ replies cannot be silently dropped; durable events remain replayable.
+ """
+
+ def __init__(self, *, max_frames: int = 1024, max_bytes: int = 8 * 1024 * 1024):
+ self._loop = asyncio.get_running_loop()
+ self._wake = asyncio.Event()
+ self._lock = threading.Lock()
+ self._frames: deque[bytes] = deque()
+ self._size = 0
+ self._closed = False
+ self._finished = False
+ self._max_frames = max_frames
+ self._max_bytes = max_bytes
+ self.overflowed = False
+
+ def send(self, frame: bytes) -> None:
+ with self._lock:
+ if self._closed or self._finished:
+ raise BrokenPipeError("WebSocket disconnected")
+ if (
+ len(self._frames) >= self._max_frames
+ or self._size + len(frame) > self._max_bytes
+ ):
+ self.overflowed = True
+ self._close_locked()
+ raise BrokenPipeError("WebSocket frame capacity exceeded")
+ wake = not self._frames
+ self._frames.append(frame)
+ self._size += len(frame)
+ if wake:
+ self._loop.call_soon_threadsafe(self._wake.set)
+
+ def close(self) -> None:
+ with self._lock:
+ self._close_locked()
+
+ def finish(self) -> None:
+ """Close after the accepted frames have been written."""
+ with self._lock:
+ self._finished = True
+ self._loop.call_soon_threadsafe(self._wake.set)
+
+ def _close_locked(self) -> None:
+ if not self._closed:
+ self._closed = True
+ self._frames.clear()
+ self._size = 0
+ self._loop.call_soon_threadsafe(self._wake.set)
+
+ async def receive(self) -> bytes | None:
+ while True:
+ await self._wake.wait()
+ with self._lock:
+ if self._closed:
+ return None
+ if self._frames:
+ frame = self._frames.popleft()
+ self._size -= len(frame)
+ if not self._frames and not self._finished:
+ self._wake.clear()
+ return frame
+ if self._finished:
+ return None
+ self._wake.clear()
+
+
+class WebSocketTransport:
+ MAX_CONNECTIONS = 32
+ # Business calls have their own bounded pool; management stays responsive.
+ MAX_REQUESTS = 8
+ DRAIN_METHODS = frozenset(
+ {
+ "initialize",
+ "shutdown",
+ "event/replay",
+ "thread/read",
+ "turn/read",
+ "turn/input/read",
+ "approval/respond",
+ "turn/interrupt",
+ "workflow/respond",
+ "workflow/interrupt",
+ "terminal/close",
+ "terminal/list",
+ "terminal/read",
+ }
+ )
+
+ def __init__(
+ self,
+ host: ServiceHost,
+ auth: BrowserAuth,
+ *,
+ native_authenticated: Callable[[web.Request], bool],
+ phase: Callable[[], str],
+ service_info: dict,
+ ) -> None:
+ self.host = host
+ self.auth = auth
+ self._native_authenticated = native_authenticated
+ self._phase = phase
+ self._service_info = service_info
+ self._connections: dict[web.WebSocketResponse, str | None] = {}
+ self._slots = asyncio.Semaphore(self.MAX_REQUESTS)
+ self._executor = ThreadPoolExecutor(
+ max_workers=self.MAX_REQUESTS, thread_name_prefix="deepcode-rpc"
+ )
+ self._inflight: set[asyncio.Future] = set()
+ self._idle = asyncio.Event()
+ self._idle.set()
+ self._closing = False
+
+ async def wait_idle(self, timeout: float) -> bool:
+ if self._idle.is_set():
+ return True
+ try:
+ await asyncio.wait_for(self._idle.wait(), timeout)
+ except TimeoutError:
+ return False
+ return True
+
+ def _guard(self, request: Request) -> None:
+ phase = self._phase()
+ if (
+ self._closing
+ or phase == "stopping"
+ or (phase != "ready" and request.method not in self.DRAIN_METHODS)
+ ):
+ raise RpcError(
+ -32000,
+ "Service is draining; request was not dispatched",
+ stable_code="SERVICE_DRAINING",
+ data={"retryable": True},
+ )
+
+ async def handle(self, request: web.Request) -> web.WebSocketResponse:
+ if "Origin" in request.headers:
+ session = self.auth.require(request)
+ elif self._native_authenticated(request):
+ session = None
+ else:
+ raise web.HTTPUnauthorized(text="Business RPC requires authentication")
+ if self._closing or self._phase() != "ready":
+ raise web.HTTPServiceUnavailable(
+ text="Service is not accepting connections"
+ )
+ if len(self._connections) >= self.MAX_CONNECTIONS:
+ raise web.HTTPServiceUnavailable(text="Too many RPC connections")
+ ws = web.WebSocketResponse(
+ heartbeat=30,
+ timeout=2,
+ max_msg_size=self.host.max_message_bytes,
+ compress=False,
+ )
+ # Reserve before the first await, including handshakes in progress.
+ self._connections[ws] = session
+ outbox = FrameQueue()
+ incoming = FrameQueue(max_frames=32, max_bytes=4 * 1024 * 1024)
+ peer = None
+ writer = expiry = dispatcher = None
+ try:
+ await ws.prepare(request)
+ if self._closing or (
+ session is not None and not self.auth.remaining(session)
+ ):
+ await _close_socket(
+ ws, code=1008, message=b"Connection authorization ended"
+ )
+ return ws
+ # The host is already started; registration only takes short locks
+ # and starts the peer pump. Keep ownership transfer uncancellable.
+ peer = self.host.connect(outbox.send, service_info=self._service_info)
+ writer = asyncio.create_task(self._write(ws, outbox))
+ dispatcher = asyncio.create_task(
+ self._dispatch(ws, peer, incoming, outbox, session)
+ )
+ if session is not None:
+ expiry = asyncio.create_task(self._expire(ws, session))
+ async for message in ws:
+ if message.type != WSMsgType.TEXT:
+ if message.type == WSMsgType.BINARY:
+ await _close_socket(
+ ws, code=1003, message=b"JSON text frames required"
+ )
+ break
+ try:
+ incoming.send(message.data.encode("utf-8"))
+ except BrokenPipeError:
+ await _close_socket(
+ ws, code=1013, message=b"Request capacity exceeded"
+ )
+ break
+ except (ConnectionError, OSError):
+ pass
+ finally:
+ incoming.close()
+ outbox.close()
+ if dispatcher is not None:
+ await asyncio.gather(dispatcher, return_exceptions=True)
+ if peer is not None:
+ await asyncio.to_thread(peer.close)
+ for task in (writer, expiry):
+ if task is not None:
+ task.cancel()
+ await asyncio.gather(
+ *(t for t in (writer, expiry) if t is not None), return_exceptions=True
+ )
+ if ws.prepared:
+ await _close_socket(ws)
+ self._connections.pop(ws, None)
+ return ws
+
+ async def _dispatch(
+ self,
+ ws: web.WebSocketResponse,
+ peer: RpcPeer,
+ incoming: FrameQueue,
+ outgoing: FrameQueue,
+ session: str | None,
+ ) -> None:
+ """Keep request order without blocking the socket's ping/pong reader."""
+ work = None
+ try:
+ while (raw := await incoming.receive()) is not None:
+ async with self._slots:
+ if ws.closed or self._closing:
+ break
+ if session is not None and not self.auth.remaining(session):
+ await _close_socket(
+ ws, code=1008, message=b"Browser session expired"
+ )
+ break
+ work = asyncio.get_running_loop().run_in_executor(
+ self._executor,
+ partial(peer.receive, raw, before_dispatch=self._guard),
+ )
+ self._inflight.add(work)
+ self._idle.clear()
+ work.add_done_callback(self._finished)
+ # Disconnect/cancellation must not cancel an admitted mutation.
+ await asyncio.shield(work)
+ if peer.closed:
+ break
+ except (ConnectionError, OSError):
+ pass
+ finally:
+ if work is not None:
+ await asyncio.gather(work, return_exceptions=True)
+ # A shutdown reply precedes the WebSocket close frame.
+ outgoing.finish()
+
+ def _finished(self, task: asyncio.Future) -> None:
+ self._inflight.discard(task)
+ if not self._inflight:
+ self._idle.set()
+
+ async def _write(self, ws: web.WebSocketResponse, outbox: FrameQueue) -> None:
+ try:
+ while (frame := await outbox.receive()) is not None:
+ await asyncio.wait_for(
+ ws.send_str(frame.decode("utf-8").rstrip("\n")), 10
+ )
+ except (TimeoutError, ConnectionError, OSError):
+ outbox.close()
+ finally:
+ await _close_socket(ws, code=1013 if outbox.overflowed else 1000)
+
+ async def _expire(self, ws: web.WebSocketResponse, session: str) -> None:
+ await asyncio.sleep(self.auth.remaining(session))
+ await _close_socket(ws, code=1008, message=b"Browser session expired")
+
+ async def revoke(self, session: str) -> None:
+ self.auth.revoke(session)
+ await asyncio.gather(
+ *(
+ _close_socket(ws, code=1008, message=b"Browser session revoked")
+ for ws, owner in tuple(self._connections.items())
+ if owner == session and ws.prepared
+ )
+ )
+
+ async def shutdown(self, _app: web.Application) -> None:
+ self._closing = True
+ await asyncio.gather(
+ *(
+ _close_socket(ws, code=1001, message=b"Service stopping")
+ for ws in tuple(self._connections)
+ if ws.prepared
+ )
+ )
+
+ async def cleanup(self, _app: web.Application) -> None:
+ await asyncio.gather(*tuple(self._inflight), return_exceptions=True)
+ await asyncio.to_thread(self._executor.shutdown, wait=True)
diff --git a/app_server/windows_task.py b/app_server/windows_task.py
new file mode 100644
index 000000000..5d0233b50
--- /dev/null
+++ b/app_server/windows_task.py
@@ -0,0 +1,391 @@
+"""User Task Scheduler adapter; no administrator service or shell wrapper."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import os
+import subprocess
+import time
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from app_server.managed_entry import read_configuration
+from app_server.service_client import ServiceClient, ServiceOperationError
+from app_server.service_state import (
+ ServiceFiles,
+ service_command,
+ service_command_port,
+ service_environment,
+ service_working_directory,
+ shell_only_variables,
+)
+from core.private_storage import open_private_file
+
+TASK_NS = "http://schemas.microsoft.com/windows/2004/02/mit/task"
+ET.register_namespace("", TASK_NS)
+
+
+def task_definition(config: dict, sid: str, label: str, configuration: Path) -> str:
+ def child(parent, name, text=None, **attributes):
+ element = ET.SubElement(parent, "{" + TASK_NS + "}" + name, attributes)
+ element.text = text
+ return element
+
+ root = ET.Element("{" + TASK_NS + "}Task", {"version": "1.4"})
+ registration = child(root, "RegistrationInfo")
+ child(registration, "Description", "DeepCode local service " + label)
+ child(registration, "URI", "\\DeepCode\\" + label)
+ trigger = child(child(root, "Triggers"), "LogonTrigger")
+ child(trigger, "Enabled", "true")
+ child(trigger, "UserId", sid)
+ principal = child(child(root, "Principals"), "Principal", id="Author")
+ child(principal, "UserId", sid)
+ child(principal, "LogonType", "InteractiveToken")
+ child(principal, "RunLevel", "LeastPrivilege")
+ settings = child(root, "Settings")
+ for name, value in {
+ "MultipleInstancesPolicy": "IgnoreNew",
+ "DisallowStartIfOnBatteries": "false",
+ "StopIfGoingOnBatteries": "false",
+ "AllowHardTerminate": "true",
+ "StartWhenAvailable": "true",
+ "RunOnlyIfNetworkAvailable": "false",
+ "AllowStartOnDemand": "true",
+ "Enabled": "true",
+ "Hidden": "true",
+ "ExecutionTimeLimit": "PT0S",
+ }.items():
+ child(settings, name, value)
+ restart = child(settings, "RestartOnFailure")
+ child(restart, "Interval", "PT1M")
+ child(restart, "Count", "3")
+ action = child(child(root, "Actions", Context="Author"), "Exec")
+ arguments = config["command"]
+ frozen = arguments[1:2] == ["--serve"]
+ program = Path(arguments[0])
+ if not frozen and program.with_name("pythonw.exe").is_file():
+ program = program.with_name("pythonw.exe")
+ child(action, "Command", str(program))
+ child(
+ action,
+ "Arguments",
+ subprocess.list2cmdline(
+ ([] if frozen else ["-m", "app_server"])
+ + ["--managed-config", str(configuration)]
+ ),
+ )
+ child(action, "WorkingDirectory", config["directory"])
+ return ET.tostring(root, encoding="unicode")
+
+
+class WindowsUserTask:
+ name = "Windows user task"
+
+ def __init__(self, files: ServiceFiles):
+ self.files = files
+ identity = hashlib.sha256(str(files.database).encode()).hexdigest()[:16]
+ self.label = f"DeepCode-{identity}"
+ self.path = files.directory / "task-configuration.json"
+
+ def _run(self, script, *, timeout=20, extra=None):
+ prelude = "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); "
+ encoded = base64.b64encode((prelude + script).encode("utf-16-le")).decode(
+ "ascii"
+ )
+ environment = {**os.environ, "DEEPCODE_TASK_LABEL": self.label, **(extra or {})}
+ try:
+ result = subprocess.run(
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-NonInteractive",
+ "-EncodedCommand",
+ encoded,
+ ],
+ check=False,
+ capture_output=True,
+ encoding="utf-8",
+ timeout=timeout,
+ env=environment,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise ServiceOperationError(
+ "Windows Task Scheduler operation did not finish; inspect service doctor"
+ ) from exc
+ if result.returncode:
+ raise ServiceOperationError(
+ "Windows Task Scheduler rejected the operation: "
+ + result.stderr.strip()[:600]
+ )
+ return result.stdout.strip()
+
+ @staticmethod
+ def _connect():
+ return (
+ "$scheduler=New-Object -ComObject Schedule.Service; $scheduler.Connect(); "
+ )
+
+ def _sid(self):
+ return self._run(
+ "[Security.Principal.WindowsIdentity]::GetCurrent().User.Value"
+ )
+
+ def available(self):
+ try:
+ self._run(self._connect())
+ return True
+ except ServiceOperationError:
+ return False
+
+ def _task(self):
+ script = (
+ self._connect()
+ + """
+try { $folder=$scheduler.GetFolder('\\DeepCode'); $task=$folder.GetTask($env:DEEPCODE_TASK_LABEL) }
+catch {
+ $failure=$_.Exception
+ while ($null -ne $failure) {
+ if ($failure.HResult -in @(-2147024894,-2147024893)) { @{registered=$false} | ConvertTo-Json -Compress; exit 0 }
+ $failure=$failure.InnerException
+ }
+ throw
+}
+@{registered=$true; state=[int]$task.State; xml=$task.Xml} | ConvertTo-Json -Compress
+"""
+ )
+ value = json.loads(self._run(script))
+ if value.get("registered"):
+ root = ET.fromstring(value["xml"])
+ ns = {"t": TASK_NS}
+ if (
+ root.findtext("t:RegistrationInfo/t:URI", namespaces=ns)
+ != "\\DeepCode\\" + self.label
+ ):
+ raise ServiceOperationError(
+ "Task identity does not match this DeepCode service"
+ )
+ if (
+ root.findtext("t:Principals/t:Principal/t:UserId", namespaces=ns)
+ != self._sid()
+ ):
+ raise ServiceOperationError("Task belongs to another Windows user")
+ return value
+
+ def _verify_definition(self, task, value):
+ if not task.get("registered"):
+ return
+ actual = ET.fromstring(task["xml"])
+ expected = ET.fromstring(
+ task_definition(value, self._sid(), self.label, self.path)
+ )
+ ns = {"t": TASK_NS}
+ for container in ("Actions", "Triggers", "Principals"):
+ if len(actual.findall(f"t:{container}/*", ns)) != 1:
+ raise ServiceOperationError(
+ "Task definition has unexpected actions or principals"
+ )
+ for path in (
+ "Actions/Exec/Command",
+ "Actions/Exec/Arguments",
+ "Actions/Exec/WorkingDirectory",
+ "Principals/Principal/LogonType",
+ "Principals/Principal/RunLevel",
+ "Triggers/LogonTrigger/UserId",
+ "Settings/ExecutionTimeLimit",
+ "Settings/MultipleInstancesPolicy",
+ "Settings/RestartOnFailure/Count",
+ ):
+ query = "/".join("t:" + part for part in path.split("/"))
+ if actual.findtext(query, namespaces=ns) != expected.findtext(
+ query, namespaces=ns
+ ):
+ raise ServiceOperationError(
+ "Task definition changed; stop and reinstall it"
+ )
+
+ def job(self):
+ if not self.path.exists():
+ return {"loaded": False, "pid": None}
+ task = self._task()
+ return {"loaded": task.get("state") in {2, 4}, "pid": None}
+
+ def read(self):
+ try:
+ value = read_configuration(self.path)
+ except FileNotFoundError:
+ return None
+ try:
+ if value["database"] != str(self.files.database):
+ raise ValueError("Wrong service database")
+ if (
+ service_command_port(value["command"], self.files.database)
+ != value["port"]
+ ):
+ raise ValueError("Wrong service port")
+ if not Path(value["directory"]).is_absolute():
+ raise ValueError("Invalid working directory")
+ except (KeyError, ValueError) as exc:
+ raise ServiceOperationError(
+ "Task configuration changed; stop and reinstall it"
+ ) from exc
+ return value
+
+ def install(self, *, port, path=None):
+ command = service_command(self.files, port)
+ service_command_port(command, self.files.database)
+ environment = service_environment(path)
+ value = {
+ "schemaVersion": 1,
+ "database": str(self.files.database),
+ "port": port,
+ "command": command,
+ "directory": str(service_working_directory(command)),
+ "environment": environment,
+ }
+ previous, task = self.read(), self._task()
+ if previous is not None:
+ self._verify_definition(task, previous)
+ if task.get("registered") and previous is None:
+ raise ServiceOperationError(
+ "An existing task has no matching DeepCode configuration"
+ )
+ if previous != value and task.get("state") in {2, 4}:
+ raise ServiceOperationError(
+ "Stop the active task before updating its configuration"
+ )
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = self.path.with_suffix(".tmp")
+ xml_path = self.path.with_suffix(".xml.tmp")
+ try:
+ with os.fdopen(
+ open_private_file(temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY),
+ "w",
+ encoding="utf-8",
+ ) as stream:
+ json.dump(value, stream)
+ stream.flush()
+ os.fsync(stream.fileno())
+ definition = task_definition(value, self._sid(), self.label, self.path)
+ with os.fdopen(
+ open_private_file(xml_path, os.O_CREAT | os.O_TRUNC | os.O_WRONLY),
+ "w",
+ encoding="utf-8",
+ ) as stream:
+ stream.write(definition)
+ # Register only a logon trigger: registration does not launch work.
+ script = (
+ self._connect()
+ + """
+try { $folder=$scheduler.GetFolder('\\DeepCode') } catch { $folder=$scheduler.GetFolder('\\').CreateFolder('DeepCode') }
+$definition=$scheduler.NewTask(0); $definition.XmlText=[IO.File]::ReadAllText($env:DEEPCODE_TASK_XML)
+$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+$null=$folder.RegisterTaskDefinition($env:DEEPCODE_TASK_LABEL,$definition,6,$sid,$null,3)
+"""
+ )
+ os.replace(temporary, self.path)
+ try:
+ self._run(script, extra={"DEEPCODE_TASK_XML": str(xml_path)})
+ except BaseException:
+ if previous is None:
+ self.path.unlink(missing_ok=True)
+ else:
+ with os.fdopen(
+ open_private_file(
+ temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY
+ ),
+ "w",
+ encoding="utf-8",
+ ) as stream:
+ json.dump(previous, stream)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, self.path)
+ raise
+ finally:
+ temporary.unlink(missing_ok=True)
+ xml_path.unlink(missing_ok=True)
+ return {"installed": True, "atLogin": True, "path": str(self.path)}
+
+ def start(self, *, port=None):
+ value = self.read()
+ task = self._task()
+ if value is None or not task.get("registered"):
+ raise ServiceOperationError("No Windows user task is installed")
+ self._verify_definition(task, value)
+ if port is not None and port != value["port"]:
+ raise ServiceOperationError(
+ "Requested port differs from the installed task"
+ )
+ self._run(
+ self._connect()
+ + "$task=$scheduler.GetFolder('\\DeepCode').GetTask($env:DEEPCODE_TASK_LABEL); $task.Enabled=$true; $null=$task.Run($null)"
+ )
+
+ def unload(self):
+ if not self.job()["loaded"]:
+ return
+ task = "$task=$scheduler.GetFolder('\\DeepCode').GetTask($env:DEEPCODE_TASK_LABEL); "
+ self._run(self._connect() + task + "$task.Enabled=$false")
+ try:
+ if self.files.running():
+ stopped = ServiceClient(self.files).call(
+ "stop", {"timeout": 10, "cancelRunning": True}, timeout=25
+ )
+ deadline = time.monotonic() + 35
+ while self.files.running():
+ current = self.files.read()
+ if (
+ current is not None
+ and current[0].instance_id != stopped["instanceId"]
+ ):
+ raise ServiceOperationError(
+ "Service was replaced during stop; the replacement was not stopped"
+ )
+ if time.monotonic() >= deadline:
+ raise ServiceOperationError(
+ "Service cleanup has not finished; inspect service logs"
+ )
+ time.sleep(0.05)
+ self._run(self._connect() + task + "$task.Stop(0)")
+ finally:
+ # Ending while disabled suppresses failure restarts; restore only
+ # the next-login setting, without issuing Run again.
+ self._run(self._connect() + task + "$task.Enabled=$true")
+
+ def uninstall(self):
+ if self.job()["loaded"]:
+ raise ServiceOperationError(
+ "Stop the Windows user task before uninstalling it"
+ )
+ self.read()
+ if self._task().get("registered"):
+ self._run(
+ self._connect()
+ + "$scheduler.GetFolder('\\DeepCode').DeleteTask($env:DEEPCODE_TASK_LABEL,0)"
+ )
+ self.path.unlink(missing_ok=True)
+ return {"installed": False, "atLogin": False, "path": str(self.path)}
+
+ def doctor(self):
+ available = self.available()
+ checks = [{"name": "taskScheduler", "ok": available}]
+ try:
+ value = self.read()
+ checks.append({"name": "configuration", "ok": value is not None})
+ if value:
+ checks.append(
+ {"name": "executable", "ok": Path(value["command"][0]).is_file()}
+ )
+ except (OSError, ValueError, ServiceOperationError) as exc:
+ checks.append({"name": "configuration", "ok": False, "message": str(exc)})
+ job = self.job() if available else {"loaded": False, "pid": None}
+ return {
+ "installed": self.path.exists(),
+ "path": str(self.path),
+ "sessionAvailable": available,
+ **job,
+ "checks": checks,
+ "shellOnlyVariables": shell_only_variables(),
+ }
diff --git a/cli/__init__.py b/cli/__init__.py
index e6a611fb3..025d441fc 100644
--- a/cli/__init__.py
+++ b/cli/__init__.py
@@ -1,17 +1,5 @@
-"""DeepCode CLI entries (P2, L5).
+"""DeepCode clients for the shared local service.
-Two frontends, both pure consumers of the SQ/EQ event stream — neither
-touches the kernel directly (DEEPCODE_V2_MASTER_PLAN.md §3 event-sourcing
-first):
-
-- ``python -m cli.tui`` — the interactive terminal UI: free-form multi-turn
- conversation, streaming output, tool progress, slash commands, session
- resume. The Claude Code / Codex CLI analogue.
-- ``python -m cli.exec_cli`` — headless one-shot: run a task, stream NDJSON
- events, exit. The CI / harness / scripting entry.
-
-CLI agent assembly lives in :mod:`core.agent_setup`; the TUI persists through
-the canonical :mod:`core.sessions` JSONL store. Desktop uses the same agent
-kernel and SessionStore through its own stdio App Server adapter, without
-routing CLI command lifecycles through Desktop application services.
+TUI, headless, Goal and MCP task entrypoints consume the service's commands and
+persistent events. The service owns Agent execution and canonical Session data.
"""
diff --git a/cli/automation_cli.py b/cli/automation_cli.py
index f91d39149..ef16c2117 100644
--- a/cli/automation_cli.py
+++ b/cli/automation_cli.py
@@ -336,8 +336,8 @@ def _print_result(command: str, result: dict[str, Any], *, as_json: bool) -> Non
f"{latest_status:<12} {automation['name']} · {automation['id']}"
)
print(
- "Interval schedules run only while a scheduler-enabled DeepCode "
- "Desktop or App Server is active."
+ "Interval schedules run while the DeepCode background service is running."
+ " Closing a client does not stop the service."
)
_print_pagination_hint(result, "Automations")
return
@@ -352,8 +352,8 @@ def _print_result(command: str, result: dict[str, Any], *, as_json: bool) -> Non
print(f"thread: {automation['threadId']}")
if command == "create" and automation["scheduleKind"] == "interval":
print(
- "Interval schedules run only while a scheduler-enabled DeepCode "
- "Desktop or App Server is active."
+ "Interval schedules run while the DeepCode background service is running."
+ " Closing a client does not stop the service."
)
return
@@ -446,8 +446,17 @@ def run(
factory = application_factory or _default_application_factory
application: DeepCodeApplication | None = None
try:
- application = factory()
- result = _dispatch(application, args)
+ if application_factory is None and args.command == "run":
+ from cli.service_automation import run_automation_service
+
+ result = run_automation_service(
+ args.automation_id,
+ request_id=args.request_id,
+ interactive=bool(not args.json and sys.stdin.isatty()),
+ )
+ else:
+ application = factory()
+ result = _dispatch(application, args)
except (ApplicationError, ValueError) as exc:
_print_error(exc, as_json=args.json)
return 1
diff --git a/cli/desktop_cli.py b/cli/desktop_cli.py
new file mode 100644
index 000000000..d856d8f00
--- /dev/null
+++ b/cli/desktop_cli.py
@@ -0,0 +1,171 @@
+"""Launch the native Desktop from an installed app or its source checkout."""
+
+from __future__ import annotations
+
+import argparse
+from importlib.metadata import PackageNotFoundError, distribution
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+from urllib.parse import urlsplit
+from urllib.request import url2pathname
+
+
+def _is_checkout(root: Path) -> bool:
+ return (root / "desktop/package.json").is_file() and (
+ root / "desktop/src-tauri/tauri.conf.json"
+ ).is_file()
+
+
+def _source_checkout() -> Path | None:
+ root = Path(__file__).resolve().parents[1]
+ if _is_checkout(root):
+ return root
+ # A normal uv tool install from a local directory records its source. This
+ # works without editable .pth hooks and without trusting the caller's cwd.
+ try:
+ data = json.loads(
+ distribution("deepcode-hku").read_text("direct_url.json") or "{}"
+ )
+ url = urlsplit(data.get("url", ""))
+ if url.scheme == "file" and url.netloc in ("", "localhost"):
+ root = Path(url2pathname(url.path))
+ if _is_checkout(root):
+ return root
+ except (PackageNotFoundError, ValueError, TypeError, OSError):
+ pass
+ return None
+
+
+def _run_source(root: Path, *, setup: bool, options: list[str]) -> int:
+ npm = shutil.which("npm")
+ if npm is None:
+ raise ValueError("Desktop source development requires Node.js 22+ and npm.")
+ if shutil.which("cargo") is None:
+ raise ValueError(
+ "Desktop source development requires Rust and the platform Tauri prerequisites."
+ )
+ desktop = root / "desktop"
+ python = (
+ root / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
+ )
+ environment = {
+ **os.environ,
+ "DEEPCODE_PYTHON": str(python if python.is_file() else sys.executable),
+ }
+ if (
+ setup
+ or not (
+ desktop
+ / "node_modules/.bin"
+ / ("tauri.cmd" if os.name == "nt" else "tauri")
+ ).is_file()
+ ):
+ subprocess.run([npm, "ci"], cwd=desktop, env=environment, check=True)
+ binary = (
+ desktop
+ / "build/sidecar/dist/deepcode-app-server"
+ / ("deepcode-app-server.exe" if os.name == "nt" else "deepcode-app-server")
+ )
+ if setup or not binary.is_file():
+ for task in ("setup:sidecar", "build:sidecar"):
+ subprocess.run([npm, "run", task], cwd=desktop, env=environment, check=True)
+ print("Opening DeepCode Desktop. Keep this development terminal open.", flush=True)
+ return subprocess.call(
+ [npm, "run", "tauri", "--", "dev", *options], cwd=desktop, env=environment
+ )
+
+
+def _installed_app() -> Path | None:
+ if sys.platform == "darwin":
+ candidates = [
+ Path.home() / "Applications/DeepCode.app",
+ Path("/Applications/DeepCode.app"),
+ ]
+ elif os.name == "nt":
+ candidates = [
+ Path(base) / "DeepCode/deepcode-desktop.exe"
+ for key in ("LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)")
+ if (base := os.environ.get(key))
+ ]
+ else:
+ candidates = [
+ Path(directory) / "deepcode-desktop" for directory in os.get_exec_path()
+ ]
+ for candidate in candidates:
+ if sys.platform == "darwin" and candidate.is_dir():
+ return candidate
+ if candidate.is_file():
+ # Do not recurse through the historical shell alias of our command.
+ with candidate.open("rb") as stream:
+ signature = stream.read(4)
+ if signature.startswith(b"MZ") or signature == b"\x7fELF":
+ return candidate
+ return None
+
+
+def _open_app(app: Path) -> int:
+ app = app.expanduser().resolve()
+ if sys.platform == "darwin" and app.suffix == ".app" and app.is_dir():
+ return subprocess.call(["open", "-a", str(app)])
+ if not app.is_file():
+ raise ValueError(f"Desktop application was not found: {app}")
+ subprocess.Popen(
+ [str(app)],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=os.name != "nt",
+ )
+ return 0
+
+
+def run(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="deepcode desktop", description="Open the native DeepCode Desktop client."
+ )
+ location = parser.add_mutually_exclusive_group()
+ location.add_argument(
+ "--app",
+ type=Path,
+ help="Installed application or AppImage at a custom location.",
+ )
+ location.add_argument(
+ "--source", type=Path, help="Use a specific trusted source checkout."
+ )
+ parser.add_argument(
+ "--setup",
+ action="store_true",
+ help="Rebuild source dependencies and Desktop resources before opening.",
+ )
+ parser.add_argument(
+ "options",
+ nargs=argparse.REMAINDER,
+ help="Optional Tauri development arguments after --.",
+ )
+ args = parser.parse_args(argv)
+ options = args.options[1:] if args.options[:1] == ["--"] else args.options
+ try:
+ source = args.source.expanduser().resolve() if args.source else None
+ if source is not None and not _is_checkout(source):
+ raise ValueError("--source must point to a DeepCode source checkout.")
+ if args.app is None:
+ source = source or _source_checkout()
+ if source is not None:
+ return _run_source(source, setup=args.setup, options=options)
+ if args.setup or options:
+ raise ValueError("--setup and Tauri arguments require a source checkout.")
+ app = args.app or _installed_app()
+ if app is None:
+ raise ValueError(
+ "Desktop is not installed. Install the Desktop app, or use deepcode desktop --source /path/to/DeepCode."
+ )
+ return _open_app(app)
+ except (ValueError, OSError, subprocess.CalledProcessError) as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ except KeyboardInterrupt:
+ return 130
diff --git a/cli/exec_cli.py b/cli/exec_cli.py
index 83df49432..6735ed12b 100644
--- a/cli/exec_cli.py
+++ b/cli/exec_cli.py
@@ -14,7 +14,8 @@
add_workspace_trust_argument,
parse_access_preset,
)
-from cli.headless_turn import HeadlessTurnOptions, run_headless_turn, succeeded
+from cli.thread_client import HeadlessTurnOptions
+from core.domain.turn import TurnStatus
from cli.transcript import TranscriptMode
from core.application.errors import ApplicationError
from core.config import ConfigError
@@ -105,7 +106,7 @@ def _approval_decider(approval: Approval) -> ApprovalStatus:
return ApprovalStatus.DENIED
-def _run(args: argparse.Namespace) -> int:
+def _run(args: argparse.Namespace, *, shared_service: bool = True) -> int:
transcript_mode = TranscriptMode.parse(
"verbose" if args.verbose else args.transcript
)
@@ -125,7 +126,17 @@ def on_event(event) -> None:
_emit_human(event, transcript_mode)
try:
- result = run_headless_turn(
+ runner_options = {}
+ if shared_service:
+ from cli.service_turn import run_service_turn
+
+ runner = run_service_turn
+ runner_options["detach"] = args.detach
+ else:
+ from cli.headless_turn import run_headless_turn
+
+ runner = run_headless_turn
+ result = runner(
HeadlessTurnOptions(
prompt=args.prompt,
workspace=workspace,
@@ -141,6 +152,7 @@ def on_event(event) -> None:
),
on_event=on_event,
decide_approval=_approval_decider,
+ **runner_options,
)
except ConfigError as exc:
print(format_config_error(exc), file=sys.stderr, flush=True)
@@ -161,14 +173,32 @@ def on_event(event) -> None:
file=sys.stderr,
flush=True,
)
- return 0 if succeeded(result) else 1
+ if args.detach:
+ print(
+ json.dumps(
+ {
+ "threadId": result.session_id,
+ "turnId": result.turn.id,
+ "status": result.turn.status.value,
+ "detached": True,
+ }
+ ),
+ flush=True,
+ )
+ return 0
+ return 0 if result.turn.status is TurnStatus.COMPLETED else 1
-def main(argv: list[str] | None = None) -> int:
+def main(argv: list[str] | None = None, *, shared_service: bool = True) -> int:
parser = argparse.ArgumentParser(
prog="deepcode exec",
description="Run one durable coding Turn headlessly.",
)
+ parser.add_argument(
+ "--detach",
+ action="store_true",
+ help="Submit to the service and return its task identity without waiting",
+ )
parser.add_argument("prompt", help="The coding task to perform.")
parser.add_argument(
"--workspace",
@@ -227,16 +257,17 @@ def main(argv: list[str] | None = None) -> int:
metavar="ID_OR_NAME",
help="Select a Skill for this Turn (repeatable, maximum 8).",
)
- parser.add_argument(
- "--max-iterations",
- type=int,
- default=None,
- help="Optional model-sampling limit for diagnostics (unlimited by default).",
- )
+ parser.set_defaults(max_iterations=None)
+ if not shared_service:
+ parser.add_argument(
+ "--max-iterations", type=int, help="Optional model-sampling limit."
+ )
args = parser.parse_args(argv)
+ if args.detach and not shared_service:
+ parser.error("--detach requires the shared service")
if len(args.skill) > MAX_SELECTED_SKILLS:
parser.error(f"--skill may be specified at most {MAX_SELECTED_SKILLS} times")
- return _run(args)
+ return _run(args, shared_service=shared_service)
if __name__ == "__main__":
diff --git a/cli/goal_runner.py b/cli/goal_runner.py
index 5ff4989a7..eeea30ef8 100644
--- a/cli/goal_runner.py
+++ b/cli/goal_runner.py
@@ -7,23 +7,15 @@
from dataclasses import dataclass
from pathlib import Path
-from cli.project_trust import (
- open_workspace_project,
- require_project_trusted,
- set_project_trusted,
-)
-from core.application.agent_adapter import ConfiguredAgentSessionFactory
-from core.application.application import DeepCodeApplication
-from core.application.errors import (
- GoalNotFoundError,
- InvalidArgumentError,
- TurnNotFoundError,
-)
+import threading
+import time
+from cli.rpc_models import from_view
+from cli.service_thread_client import ServiceThreadClient
+from core.application.errors import GoalNotFoundError, InvalidArgumentError
from core.domain.execution_security import ExecutionAccessPreset
from core.domain.message_provenance import ClientSurface
from core.domain.thread_goal import GoalOutcome, ThreadGoal, ThreadGoalStatus
from core.events import Event
-from core.harness.permissions import PermissionMode
@dataclass(frozen=True, slots=True)
@@ -37,7 +29,6 @@ class GoalRunOptions:
skill_identifiers: tuple[str, ...] = ()
completion_evidence_command: str = ""
token_budget: int | None = None
- max_iterations: int | None = None
trust_workspace: bool = False
access_preset: ExecutionAccessPreset | None = None
@@ -50,7 +41,6 @@ class GoalResumeOptions:
model: str | None = None
reasoning_effort: str | None = None
token_budget: int | None = None
- max_iterations: int | None = None
trust_workspace: bool = False
access_preset: ExecutionAccessPreset | None = None
@@ -68,187 +58,143 @@ class GoalRunResult:
async def run_goal(
- options: GoalRunOptions,
- *,
- on_progress: ProgressHook | None = None,
- on_event: EventHook | None = None,
+ options: GoalRunOptions, *, on_progress=None, on_event=None
) -> GoalRunResult:
- """Create a canonical Session and wait on its ordinary-Turn Goal lifecycle."""
+ return await _run_attached(options, on_progress=on_progress, on_event=on_event)
- workspace = Path(options.workspace).expanduser().resolve()
- workspace.mkdir(parents=True, exist_ok=True)
- objective = _objective_with_completion_evidence(
- options.objective,
- options.completion_evidence_command,
- )
- factory = ConfiguredAgentSessionFactory(
- default_permission_mode=PermissionMode.DEFAULT,
- streaming=False,
- max_iterations=options.max_iterations,
- )
- application = DeepCodeApplication.open(
- session_factory=factory,
- host_surface="headless",
- run_automation_scheduler=False,
- )
- event_token: str | None = None
+
+async def resume_goal(
+ options: GoalResumeOptions, *, on_progress=None, on_event=None
+) -> GoalRunResult:
+ if not options.session_id.strip():
+ raise InvalidArgumentError("Session ID must not be empty")
+ return await _run_attached(options, on_progress=on_progress, on_event=on_event)
+
+
+async def _run_attached(options, *, on_progress, on_event):
+ detached = threading.Event()
try:
- project = open_workspace_project(
- application,
- str(workspace),
- grant_trust=options.trust_workspace,
- )
- require_project_trusted(project)
- if options.skill_ids and options.skill_identifiers:
- raise InvalidArgumentError("pass Skill IDs or Skill identifiers, not both")
- skill_ids = options.skill_ids or tuple(
- application.skills.select(project.id, identifier).id
- for identifier in options.skill_identifiers
- )
- thread = application.threads.start(
- project.id,
- title=objective.splitlines()[0][:60],
- # Automated goal runs choose their own composition; an
- # interactive default must not narrow them silently.
- inherit_default_preset=False,
- connection_id=options.connection_id,
- model=options.model,
- reasoning_effort=options.reasoning_effort,
- access_preset_override=options.access_preset,
- )
- if on_event is not None:
- event_token = application.turns.subscribe_thread_events(
- thread.id,
- on_event,
- )
- goal = application.goals.create(
- thread.id,
- objective=objective,
- token_budget=options.token_budget,
- skill_ids=skill_ids,
- start=True,
- client_surface=ClientSurface.HEADLESS,
- )
- return await _wait_for_goal(
- application,
- thread_id=thread.id,
- workspace=thread.workspace_path,
- initial=goal,
- on_progress=on_progress,
+ return await asyncio.to_thread(
+ _execute, options, on_progress, on_event, detached
)
finally:
- if event_token is not None:
- application.turns.unsubscribe_thread_events(event_token)
- application.close()
+ detached.set()
-async def resume_goal(
- options: GoalResumeOptions,
- *,
- on_progress: ProgressHook | None = None,
- on_event: EventHook | None = None,
-) -> GoalRunResult:
- """Resume the existing Goal without replacing Session identity or history."""
-
- session_id = options.session_id.strip()
- if not session_id:
- raise InvalidArgumentError("Session ID must not be empty")
- workspace_override = (
- str(Path(options.workspace).expanduser().resolve())
- if options.workspace is not None
- else None
+def _execute(options, on_progress, on_event, detached) -> GoalRunResult:
+ resuming = isinstance(options, GoalResumeOptions)
+ objective = (
+ None
+ if resuming
+ else _objective_with_completion_evidence(
+ options.objective, options.completion_evidence_command
+ )
)
- factory = ConfiguredAgentSessionFactory(
- default_permission_mode=PermissionMode.DEFAULT,
+ if not resuming and options.skill_ids and options.skill_identifiers:
+ raise InvalidArgumentError("pass Skill IDs or Skill identifiers, not both")
+ if options.workspace is not None:
+ Path(options.workspace).expanduser().mkdir(parents=True, exist_ok=True)
+ client = ServiceThreadClient(
+ workspace=options.workspace,
+ model=options.model,
+ connection_id=options.connection_id,
+ reasoning_effort=options.reasoning_effort,
+ max_iterations=None,
streaming=False,
- max_iterations=options.max_iterations,
+ trust_workspace=options.trust_workspace,
+ resume_id=options.session_id if resuming else None,
+ event_sink=on_event,
+ surface="headless",
)
- application = DeepCodeApplication.open(
- session_factory=factory,
- host_surface="headless",
- run_automation_scheduler=False,
- )
- event_token: str | None = None
try:
- thread = application.threads.resume(
- session_id,
- workspace_path=workspace_override,
- )
- project = application.projects.read(thread.project_id)
- if options.trust_workspace:
- project = set_project_trusted(application, project)
- require_project_trusted(project)
if options.access_preset is not None:
- thread = application.threads.set_access_preset(
- thread.id,
- options.access_preset,
+ client.set_access_preset(options.access_preset)
+ if resuming:
+ goal = client.goals.read(client.thread.id)
+ if goal is None:
+ raise GoalNotFoundError(
+ f"no Goal is attached to Session {client.thread.id}"
+ )
+ if goal.status is ThreadGoalStatus.COMPLETE:
+ return GoalRunResult(
+ goal,
+ client.thread.id,
+ client.workspace,
+ client.goals.read_outcome(client.thread.id),
+ )
+ goal = _apply_budget_override(
+ client.goals, goal=goal, token_budget=options.token_budget
)
- goal = application.goals.read(thread.id)
- if goal is None:
- raise GoalNotFoundError(f"no Goal is attached to Session {thread.id}")
- if goal.status is ThreadGoalStatus.COMPLETE:
- return _result(
- application,
- goal=goal,
- workspace=thread.workspace_path,
+ execution = dict(
+ connection_id=options.connection_id,
+ model=options.model,
+ reasoning_effort=options.reasoning_effort,
)
- if on_event is not None:
- event_token = application.turns.subscribe_thread_events(
- thread.id,
- on_event,
+ if goal.status is ThreadGoalStatus.ACTIVE:
+ goal = client.goals.continue_goal(
+ client.thread.id, expected_goal_id=goal.id, **execution
+ ).goal
+ elif goal.status in {
+ ThreadGoalStatus.PAUSED,
+ ThreadGoalStatus.BLOCKED,
+ ThreadGoalStatus.BUDGET_LIMITED,
+ }:
+ goal = client.goals.resume(
+ client.thread.id, expected_goal_id=goal.id, **execution
+ )
+ else:
+ raise InvalidArgumentError(
+ f"Goal status cannot be resumed: {goal.status.value}"
+ )
+ else:
+ skills = options.skill_ids or tuple(
+ client.skills.select(client.project.id, value).id
+ for value in options.skill_identifiers
)
-
- goal = _apply_budget_override(
- application,
- goal=goal,
- token_budget=options.token_budget,
- )
- execution_options = {
- "client_surface": ClientSurface.HEADLESS,
- "connection_id": options.connection_id,
- "model": options.model,
- "reasoning_effort": options.reasoning_effort,
- }
- if goal.status is ThreadGoalStatus.ACTIVE:
- continued = application.goals.continue_goal(
- thread.id,
- expected_goal_id=goal.id,
- **execution_options,
- )
- goal = continued.goal
- elif goal.status in {
- ThreadGoalStatus.PAUSED,
- ThreadGoalStatus.BLOCKED,
- ThreadGoalStatus.BUDGET_LIMITED,
- }:
- goal = application.goals.resume(
- thread.id,
- expected_goal_id=goal.id,
- **execution_options,
+ client.rename_thread(objective.splitlines()[0][:60])
+ goal = client.goals.create(
+ client.thread.id,
+ objective=objective,
+ token_budget=options.token_budget,
+ skill_ids=skills,
)
- else: # pragma: no cover - exhaustive guard for future statuses
- raise InvalidArgumentError(
- f"Goal status cannot be resumed: {goal.status.value}"
- )
-
- return await _wait_for_goal(
- application,
- thread_id=thread.id,
- workspace=thread.workspace_path,
- initial=goal,
- on_progress=on_progress,
- )
+ last_snapshot = None
+ while not detached.is_set():
+ state = client.rpc.call("thread/goal/get", {"threadId": client.thread.id})
+ if "executionSettled" not in state:
+ raise InvalidArgumentError(
+ "Restart the service with the updated installation to inspect Goal completion"
+ )
+ current = from_view(ThreadGoal, state["goal"]) if state["goal"] else None
+ if current is None or current.id != goal.id:
+ raise InvalidArgumentError("The attached Goal was cleared or replaced")
+ goal = current
+ client.drain_events()
+ settled = state["executionSettled"]
+ if (
+ on_progress
+ and goal != last_snapshot
+ and (goal.status is ThreadGoalStatus.ACTIVE or settled)
+ ):
+ on_progress(goal)
+ last_snapshot = goal
+ if settled:
+ return GoalRunResult(
+ goal,
+ client.thread.id,
+ client.workspace,
+ from_view(GoalOutcome, state["outcome"])
+ if state["outcome"]
+ else None,
+ )
+ time.sleep(0.05)
+ raise InterruptedError("Client detached; the Goal continues in the service")
finally:
- if event_token is not None:
- application.turns.unsubscribe_thread_events(event_token)
- application.close()
+ asyncio.run(client.close())
def _apply_budget_override(
- application: DeepCodeApplication,
- *,
- goal: ThreadGoal,
- token_budget: int | None,
+ goals, *, goal: ThreadGoal, token_budget: int | None
) -> ThreadGoal:
if token_budget is None:
if (
@@ -256,16 +202,14 @@ def _apply_budget_override(
and goal.token_budget is not None
):
raise InvalidArgumentError(
- "the Goal exhausted its token budget; provide a larger "
- "--token-budget to resume"
+ "the Goal exhausted its token budget; provide a larger --token-budget to resume"
)
return goal
if token_budget <= goal.tokens_used:
raise InvalidArgumentError(
- "the resumed token budget must be greater than tokens already used "
- f"({goal.tokens_used})"
+ f"the resumed token budget must be greater than tokens already used ({goal.tokens_used})"
)
- return application.goals.edit(
+ return goals.edit(
goal.thread_id,
expected_goal_id=goal.id,
objective=goal.objective,
@@ -276,67 +220,6 @@ def _apply_budget_override(
)
-async def _wait_for_goal(
- application: DeepCodeApplication,
- *,
- thread_id: str,
- workspace: str,
- initial: ThreadGoal,
- on_progress: ProgressHook | None,
-) -> GoalRunResult:
- goal = initial
- last_snapshot: ThreadGoal | None = None
- while True:
- settled = _goal_execution_settled(application, goal)
- if (
- on_progress is not None
- and goal != last_snapshot
- and (goal.status is ThreadGoalStatus.ACTIVE or settled)
- ):
- on_progress(goal)
- last_snapshot = goal
- if settled:
- break
- await asyncio.sleep(0.05)
- goal = application.goals.read(thread_id) or goal
- return _result(application, goal=goal, workspace=workspace)
-
-
-def _goal_execution_settled(
- application: DeepCodeApplication,
- goal: ThreadGoal,
-) -> bool:
- if goal.status is ThreadGoalStatus.ACTIVE:
- return False
- outcome = application.goals.read_outcome(goal.thread_id)
- deciding_turn_id = outcome.decided_by_turn_id if outcome is not None else None
- if deciding_turn_id is None:
- return True
- try:
- deciding_turn = application.turns.read(deciding_turn_id).turn
- except TurnNotFoundError:
- return True
- return deciding_turn.status.is_terminal and application.goals.is_turn_accounted(
- goal.thread_id,
- goal_id=goal.id,
- turn_id=deciding_turn.id,
- )
-
-
-def _result(
- application: DeepCodeApplication,
- *,
- goal: ThreadGoal,
- workspace: str,
-) -> GoalRunResult:
- return GoalRunResult(
- goal=goal,
- session_id=goal.thread_id,
- workspace=workspace,
- outcome=application.goals.read_outcome(goal.thread_id),
- )
-
-
def _objective_with_completion_evidence(objective: str, command: str) -> str:
clean = objective.strip()
if not clean:
diff --git a/cli/headless_turn.py b/cli/headless_turn.py
index d72400b66..205bb3155 100644
--- a/cli/headless_turn.py
+++ b/cli/headless_turn.py
@@ -4,9 +4,9 @@
import threading
from collections.abc import Callable
-from dataclasses import dataclass
from pathlib import Path
+from cli.thread_client import HeadlessTurnOptions, HeadlessTurnResult
from cli.project_trust import (
open_workspace_project,
require_project_trusted,
@@ -17,35 +17,11 @@
from core.domain.approval import Approval, ApprovalStatus
from core.domain.common import new_id
from core.domain.execution_profile import ExecutionSelection
-from core.domain.execution_security import ExecutionAccessPreset
from core.domain.message_provenance import ClientSurface
-from core.domain.turn import Turn, TurnStatus
from core.events import Event
from core.harness.permissions import PermissionMode
-@dataclass(frozen=True, slots=True)
-class HeadlessTurnOptions:
- prompt: str
- workspace: str | None = None
- resume_id: str | None = None
- connection_id: str | None = None
- model: str | None = None
- reasoning_effort: str | None = None
- skill_identifiers: tuple[str, ...] = ()
- max_iterations: int | None = None
- trust_workspace: bool = False
- access_preset: ExecutionAccessPreset | None = None
- agent_preset: str | None = None
-
-
-@dataclass(frozen=True, slots=True)
-class HeadlessTurnResult:
- turn: Turn
- session_id: str
- workspace: str
-
-
EventHook = Callable[[Event], None]
ApprovalDecider = Callable[[Approval], ApprovalStatus]
@@ -210,15 +186,4 @@ def observe(event: Event) -> None:
application.close()
-def succeeded(result: HeadlessTurnResult) -> bool:
- return result.turn.status is TurnStatus.COMPLETED
-
-
-__all__ = [
- "ApprovalDecider",
- "EventHook",
- "HeadlessTurnOptions",
- "HeadlessTurnResult",
- "run_headless_turn",
- "succeeded",
-]
+__all__ = ["run_headless_turn"]
diff --git a/cli/loop_cli.py b/cli/loop_cli.py
index 5d76b62d5..5f1e1008e 100644
--- a/cli/loop_cli.py
+++ b/cli/loop_cli.py
@@ -97,7 +97,6 @@ def on_progress(goal) -> None:
connection_id=args.connection,
reasoning_effort=args.reasoning_effort,
token_budget=args.token_budget,
- max_iterations=args.max_iterations,
trust_workspace=args.trust,
access_preset=parse_access_preset(args.access),
),
@@ -118,7 +117,6 @@ def on_progress(goal) -> None:
reasoning_effort=args.reasoning_effort,
skill_identifiers=tuple(args.skill),
token_budget=args.token_budget,
- max_iterations=args.max_iterations,
trust_workspace=args.trust,
access_preset=parse_access_preset(args.access),
),
@@ -217,12 +215,6 @@ def main(argv: list[str] | None = None) -> int:
default=None,
help="Total budget for a new Goal, or a larger budget when resuming.",
)
- parser.add_argument(
- "--max-iterations",
- type=int,
- default=None,
- help="Optional model-sampling limit for diagnostics (unlimited by default).",
- )
args = parser.parse_args(argv)
if (args.goal is None) == (args.resume is None):
parser.error("provide exactly one of GOAL or --resume SESSION_ID")
diff --git a/cli/mcp_server.py b/cli/mcp_server.py
index 550b9cf69..43c94769c 100644
--- a/cli/mcp_server.py
+++ b/cli/mcp_server.py
@@ -4,17 +4,11 @@
servers for tools, this lets any MCP client (another agent, an IDE, a second
DeepCode) drive DeepCode itself as a coding sub-agent over stdio.
-Ported from the reference agent's ``mcp-server/`` (``codex_tool_config`` +
-``codex_tool_runner`` + ``message_processor``), adapted to DeepCode's ``mcp``
-SDK and :func:`core.agent_setup.build_agent_session`. Two tools are exposed:
-
-- ``deepcode`` — run a coding task on a prompt (starts a session)
-- ``deepcode-reply`` — continue a prior session by id (multi-turn)
-
-Every call runs through ``build_agent_session``, so it inherits the full agent
-— native tools, hooks, summarization compaction, sandbox, spawn_agent
-delegation, skills, and memory. The transport is stdio; the JSON-RPC channel is
-stdout, so all logging must stay on stderr (configured below).
+The stdio server submits durable Turns to the shared local service. The
+``deepcode`` tool starts a Session; ``deepcode-reply`` resumes its canonical id.
+Workspace trust and tool approval remain enforced by the service. Closing MCP
+stdio detaches the client; it does not close the Agent or erase Session history.
+Logs stay on stderr so stdout remains the MCP protocol channel.
"""
from __future__ import annotations
@@ -22,17 +16,12 @@
import asyncio
import os
import sys
-import uuid
from typing import Any
import mcp.types as types
from mcp.server.lowlevel import Server
from mcp.server.stdio import stdio_server
-# Sessions kept alive for ``deepcode-reply`` follow-ups, keyed by the id we
-# return from a ``deepcode`` call (mirrors the reference's thread_id map).
-_SESSIONS: dict[str, Any] = {}
-
_DEEPCODE_TOOL = types.Tool(
name="deepcode",
title="DeepCode",
@@ -69,7 +58,7 @@
title="DeepCode reply",
description=(
"Continue an existing DeepCode session (started by a prior deepcode "
- "call) with a follow-up prompt. The session keeps its full history."
+ "call) with a follow-up prompt. The shared service keeps its full history across MCP connections."
),
inputSchema={
"type": "object",
@@ -89,52 +78,62 @@
)
-async def _run_turn(session: Any, prompt: str) -> tuple[str, str]:
- """Run one turn on ``session`` and return (final_text, stop_reason)."""
- from core.events import UserInput
-
- final, stop_reason = "", "completed"
- async for event in session.run_stream(UserInput(text=prompt)):
- if event.msg.type == "task_complete":
- final = event.msg.final_text or ""
- stop_reason = event.msg.stop_reason or "completed"
- return (final.strip() or "(the agent produced no summary)"), stop_reason
-
-
def _reply(
text: str, structured: dict[str, Any]
) -> tuple[list[types.TextContent], dict[str, Any]]:
return [types.TextContent(type="text", text=text)], structured
-async def _handle_deepcode(arguments: dict[str, Any]):
- from core.agent_setup import build_agent_session
+async def _run_task(prompt: str, *, workspace=None, model=None, session_id=None):
+ from cli.thread_client import HeadlessTurnOptions
+ from cli.service_turn import run_service_turn_async
+ from core.application.errors import ApplicationError
+
+ summary = ""
+ def on_event(event):
+ nonlocal summary
+ if event.msg.type == "agent_message" and event.msg.phase.value == "final_answer":
+ summary = event.msg.text
+ elif event.msg.type == "task_complete":
+ summary = event.msg.final_text or summary
+
+ try:
+ result = await run_service_turn_async(
+ HeadlessTurnOptions(
+ prompt=prompt, workspace=workspace, model=model, resume_id=session_id
+ ),
+ on_event=on_event,
+ )
+ except (ApplicationError, OSError, ValueError) as exc:
+ return _reply(f"Error: {exc}", {"error": getattr(exc, "code", "task failed")})
+ return _reply(
+ summary.strip() or "(the agent produced no summary)",
+ {
+ "session_id": result.session_id,
+ "stop_reason": result.turn.stop_reason or result.turn.status.value,
+ },
+ )
+
+
+async def _handle_deepcode(arguments: dict[str, Any]):
prompt = str(arguments.get("prompt") or "").strip()
if not prompt:
return _reply("Error: 'prompt' is required.", {"error": "missing prompt"})
workspace = os.path.abspath(str(arguments.get("workspace") or os.getcwd()))
- model = arguments.get("model") or None
- session, _model, _engine = build_agent_session(workspace=workspace, model=model)
- session_id = uuid.uuid4().hex
- _SESSIONS[session_id] = session
- final, stop_reason = await _run_turn(session, prompt)
- return _reply(final, {"session_id": session_id, "stop_reason": stop_reason})
+ return await _run_task(
+ prompt, workspace=workspace, model=arguments.get("model") or None
+ )
async def _handle_reply(arguments: dict[str, Any]):
- session_id = str(arguments.get("session_id") or "")
- session = _SESSIONS.get(session_id)
- if session is None:
- return _reply(
- f"Error: no such session {session_id!r}. Start one with the deepcode tool first.",
- {"error": "unknown session"},
- )
+ session_id = str(arguments.get("session_id") or "").strip()
+ if not session_id:
+ return _reply("Error: 'session_id' is required.", {"error": "missing session"})
prompt = str(arguments.get("prompt") or "").strip()
if not prompt:
return _reply("Error: 'prompt' is required.", {"error": "missing prompt"})
- final, stop_reason = await _run_turn(session, prompt)
- return _reply(final, {"session_id": session_id, "stop_reason": stop_reason})
+ return await _run_task(prompt, session_id=session_id)
def build_server() -> Server:
diff --git a/cli/provider_cli.py b/cli/provider_cli.py
index 37af30bff..a09641224 100644
--- a/cli/provider_cli.py
+++ b/cli/provider_cli.py
@@ -6,6 +6,7 @@
import getpass
import json
import sys
+import time
from core.application.errors import ApplicationError
from core.application.llm_configuration_service import LLMConfigurationService
@@ -29,6 +30,21 @@ def _parser() -> argparse.ArgumentParser:
add.add_argument("--template")
add.add_argument("--label")
add.add_argument("--adapter", choices=("openai_compat", "anthropic"))
+ add.add_argument(
+ "--protocol",
+ choices=("auto", "openai_chat", "openai_responses", "anthropic_messages"),
+ )
+ add.add_argument("--auth", choices=("api_key", "none", "oauth"))
+ add.add_argument(
+ "--compat",
+ type=json.loads,
+ help="Typed compatibility object (JSON); unknown fields are rejected.",
+ )
+ add.add_argument(
+ "--model-declarations",
+ type=json.loads,
+ help="Model declaration array (JSON), including capacities and capabilities.",
+ )
add.add_argument("--api-base")
add.add_argument("--api-key-env")
add.add_argument(
@@ -57,11 +73,30 @@ def _parser() -> argparse.ArgumentParser:
)
command_parsers.append(test)
test.add_argument("id")
+ test.add_argument(
+ "--agent",
+ action="store_true",
+ help="Verify streaming and a local tool round trip: up to 3 model requests, 90 seconds; no shell or file tools.",
+ )
test.add_argument(
"--model",
help="Send a minimal real inference request to this model.",
)
+ login = commands.add_parser(
+ "login",
+ help="Sign in to a saved OpenRouter OAuth connection (up to 5 minutes).",
+ )
+ command_parsers.append(login)
+ login.add_argument("id")
+ login.add_argument("--no-browser", action="store_true")
+ logout = commands.add_parser(
+ "logout",
+ help="Disconnect a Provider account locally; remote key revocation stays in the Provider settings.",
+ )
+ command_parsers.append(logout)
+ logout.add_argument("id")
+
models = commands.add_parser("models", help="List models for a connection.")
command_parsers.append(models)
models.add_argument("id")
@@ -91,6 +126,9 @@ def run(argv: list[str] | None = None) -> int:
("template", "template"),
("label", "label"),
("adapter", "adapter"),
+ ("protocol", "protocol"),
+ ("auth", "auth"),
+ ("compat", "compat"),
("api_base", "apiBase"),
("api_key_env", "apiKeyEnv"),
("catalog", "modelCatalog"),
@@ -100,6 +138,10 @@ def run(argv: list[str] | None = None) -> int:
candidate = getattr(args, argument)
if candidate is not None:
value[field] = candidate
+ if args.model_declarations is not None:
+ if args.model:
+ raise ValueError("Use either --model or --model-declarations")
+ value["manualModels"] = args.model_declarations
if args.clear_api_key:
value["clearApiKey"] = True
if args.api_key:
@@ -107,8 +149,31 @@ def run(argv: list[str] | None = None) -> int:
result = service.upsert(value)
elif args.command == "remove":
result = service.remove(args.id)
+ elif args.command == "login":
+ flow = service.login_start(args.id, open_browser=not args.no_browser)
+ try:
+ if flow["authorizationUrl"]:
+ print(
+ flow["authorizationUrl"],
+ file=sys.stderr if args.json else sys.stdout,
+ )
+ while flow["status"] in {"starting", "pending", "exchanging"}:
+ time.sleep(0.5)
+ flow = service.login_poll(flow["flowId"])
+ result = flow
+ except KeyboardInterrupt:
+ service.login_cancel(flow["flowId"])
+ return 130
+ finally:
+ service.close()
+ elif args.command == "logout":
+ result = service.logout(args.id)
elif args.command == "test":
- result = service.test(args.id, model_id=args.model)
+ result = service.test(
+ args.id,
+ model_id=args.model,
+ **({"mode": "agent"} if args.agent else {}),
+ )
else:
result = service.list_models(args.id, refresh=args.refresh)
except (ApplicationError, ValueError) as exc:
@@ -117,6 +182,13 @@ def run(argv: list[str] | None = None) -> int:
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
+ elif args.command in {"login", "logout"}:
+ if args.command == "login":
+ print(
+ f"{result['status']}: {result.get('accountId') or result.get('error') or args.id}"
+ )
+ else:
+ print("Disconnected locally. Remote key settings: " + result["manageUrl"])
elif args.command == "list" or args.command in {"set", "remove"}:
for connection in result["connections"]:
state = "ready" if connection["configured"] else "credential needed"
@@ -150,7 +222,7 @@ def run(argv: list[str] | None = None) -> int:
)
if result.get("error"):
print(f"warning: {result['error']}", file=sys.stderr)
- return 0
+ return 1 if args.command == "login" and result["status"] != "authenticated" else 0
if __name__ == "__main__":
diff --git a/cli/rpc_models.py b/cli/rpc_models.py
new file mode 100644
index 000000000..78500dee0
--- /dev/null
+++ b/cli/rpc_models.py
@@ -0,0 +1,47 @@
+"""Decode the existing domain wire views without rewriting arbitrary JSON keys."""
+
+from __future__ import annotations
+
+from dataclasses import fields, is_dataclass
+from functools import lru_cache
+from typing import get_args, get_origin, get_type_hints
+
+from pydantic import TypeAdapter
+from pydantic.alias_generators import to_camel
+
+
+@lru_cache(maxsize=32)
+def _adapter(model):
+ return TypeAdapter(model)
+
+
+@lru_cache(maxsize=32)
+def _fields(model):
+ hints = get_type_hints(model)
+ return [
+ (field.name, to_camel(field.name), hints[field.name]) for field in fields(model)
+ ]
+
+
+def _input(model, value):
+ if value is None:
+ return None
+ if is_dataclass(model) and isinstance(value, dict):
+ return {
+ name: _input(hint, value[wire])
+ for name, wire, hint in _fields(model)
+ if wire in value
+ }
+ origin, args = get_origin(model), get_args(model)
+ if origin in (list, tuple) and isinstance(value, (list, tuple)) and args:
+ return [_input(args[0], item) for item in value]
+ # Optional dataclasses need field aliases too. Dict/JSON payloads remain
+ # untouched: keys such as tool arguments are application data, not fields.
+ for candidate in args:
+ if is_dataclass(candidate):
+ return _input(candidate, value)
+ return value
+
+
+def from_view(model, value):
+ return _adapter(model).validate_python(_input(model, value))
diff --git a/cli/schedule_cli.py b/cli/schedule_cli.py
index bd5831fc2..176224df0 100644
--- a/cli/schedule_cli.py
+++ b/cli/schedule_cli.py
@@ -43,12 +43,45 @@ def _autodream_task(
connection_id: str | None,
reasoning_effort: str | None,
):
+ async def prompt_runner(prompt):
+ from cli.thread_client import HeadlessTurnOptions
+ from cli.service_turn import run_service_turn_async
+
+ summary = ""
+
+ def on_event(event):
+ nonlocal summary
+ if (
+ event.msg.type == "agent_message"
+ and event.msg.phase.value == "final_answer"
+ ):
+ summary = event.msg.text
+ elif event.msg.type == "task_complete":
+ summary = event.msg.final_text or summary
+
+ result = await run_service_turn_async(
+ HeadlessTurnOptions(
+ prompt=prompt,
+ workspace=workspace,
+ model=model,
+ connection_id=connection_id,
+ reasoning_effort=reasoning_effort,
+ ),
+ on_event=on_event,
+ )
+ if result.turn.status.value != "completed":
+ raise RuntimeError(
+ result.turn.error_message or "Memory maintenance did not complete"
+ )
+ return result.turn.stop_reason or "completed", summary
+
async def task(run_index: int) -> RunOutcome:
result = await consolidate_memory(
workspace,
model=model,
connection_id=connection_id,
reasoning_effort=reasoning_effort,
+ prompt_runner=prompt_runner,
)
detail = (
f"{result.notes_before}->{result.notes_after} notes"
@@ -75,7 +108,6 @@ async def task(run_index: int) -> RunOutcome:
connection_id=args.connection,
reasoning_effort=args.reasoning_effort,
token_budget=args.token_budget,
- max_iterations=args.max_iterations,
)
)
return RunOutcome(
@@ -149,12 +181,6 @@ def main(argv: list[str] | None = None) -> int:
)
parser.add_argument("--max-runs", type=int, default=5)
parser.add_argument("--token-budget", type=int, default=None)
- parser.add_argument(
- "--max-iterations",
- type=int,
- default=None,
- help="Optional model-sampling limit for diagnostics (unlimited by default).",
- )
parser.add_argument("--once", action="store_true", help="Run a single pass.")
args = parser.parse_args(argv)
if args.job == "loop" and not args.goal:
diff --git a/cli/service_automation.py b/cli/service_automation.py
new file mode 100644
index 000000000..ce714464a
--- /dev/null
+++ b/cli/service_automation.py
@@ -0,0 +1,120 @@
+"""Monitor a manual Automation Run owned by the shared service."""
+
+from __future__ import annotations
+
+import time
+
+from app_server.blocking_client import BlockingServiceClient
+from app_server.service_state import ServiceFiles
+from cli.automation_foreground import (
+ ForegroundApprovalRequiredError,
+ _foreground_settled,
+)
+from cli.exec_cli import _approval_decider
+from cli.rpc_models import from_view
+from core.domain.approval import Approval
+from core.domain.common import new_id
+from core.persistence.database import default_database_path
+
+
+def run_automation_service(automation_id, *, request_id, interactive):
+ rpc = BlockingServiceClient(
+ ServiceFiles(default_database_path()), surface="headless"
+ )
+ try:
+ result = rpc.call(
+ "automation/run",
+ {
+ "automationId": automation_id,
+ "requestId": request_id or new_id("manual"),
+ },
+ )
+ run = result["run"]
+ thread_id, run_id = run["threadId"], run["id"]
+ sequence = rpc.call("event/replay", {"threadId": thread_id, "limit": 1})[
+ "headSequence"
+ ]
+ # The Run may settle between admission and reading the event head.
+ # Read its current projection once before relying on incremental replay.
+ offset = 0
+ while True:
+ page = rpc.call(
+ "automation/runs",
+ {"automationId": automation_id, "offset": offset, "limit": 100},
+ )
+ current = next(
+ (candidate for candidate in page["runs"] if candidate["id"] == run_id),
+ None,
+ )
+ if current is not None:
+ run = current
+ break
+ if not page["hasMore"]:
+ raise RuntimeError("The admitted Automation Run is no longer available")
+ offset = page["nextOffset"]
+ while True:
+ # Listing reconciles crash-interrupted Goal/Run projections in the
+ # existing service; the CLI never performs that recovery itself.
+ rpc.call("automation/runs", {"automationId": automation_id, "limit": 1})
+ through = None
+ while True:
+ page = rpc.call(
+ "event/replay",
+ {
+ "threadId": thread_id,
+ "after": sequence,
+ **({"through": through} if through is not None else {}),
+ },
+ )
+ through = page["headSequence"] if through is None else through
+ for event in page["events"]:
+ sequence = event["sequence"]
+ candidate = event["payload"].get("run")
+ if (
+ event["type"] == "automation.updated"
+ and candidate
+ and candidate["id"] == run_id
+ ):
+ run = candidate
+ if not page["hasMore"]:
+ break
+ if _foreground_settled(run):
+ turn = (
+ rpc.call("turn/read", {"turnId": run["turnId"]})["turn"]
+ if run.get("turnId")
+ else None
+ )
+ return {"run": run, "turn": turn}
+ active = rpc.call(
+ "turn/list", {"threadId": thread_id, "state": "active", "limit": 1}
+ )["turns"]
+ if active and active[0].get("goalId") == run.get("goalId"):
+ snapshot = rpc.call("turn/read", {"turnId": active[0]["id"]})
+ for value in snapshot["approvals"]:
+ if value["status"] != "pending":
+ continue
+ approval = from_view(Approval, value)
+ if not interactive:
+ raise ForegroundApprovalRequiredError(
+ "Automation requires human approval in an interactive DeepCode client",
+ details={
+ "automationId": automation_id,
+ "runId": run_id,
+ "threadId": thread_id,
+ "turnId": approval.turn_id,
+ "approvalId": approval.id,
+ "cleanup": "service_keeps_waiting",
+ },
+ )
+ decision = _approval_decider(approval)
+ try:
+ rpc.call(
+ "approval/respond",
+ {"approvalId": approval.id, "decision": decision.value},
+ )
+ except Exception as exc:
+ if getattr(exc, "code", None) != "APPROVAL_ALREADY_RESOLVED":
+ raise
+ time.sleep(0.25)
+ finally:
+ rpc.close()
diff --git a/cli/service_catalogs.py b/cli/service_catalogs.py
new file mode 100644
index 000000000..9e5b0a68f
--- /dev/null
+++ b/cli/service_catalogs.py
@@ -0,0 +1,249 @@
+"""Typed RPC adapters for the TUI's existing catalog and Goal commands."""
+
+from __future__ import annotations
+
+from cli.rpc_models import from_view
+from core.application.errors import InvalidArgumentError
+from core.application.goal_extension import GoalContinueResult
+from core.application.mcp_service import McpInventory, McpPresetInventory
+from core.application.plugin_service import PluginDiscovery
+from core.application.skill_service import SkillDiscovery, SkillInfo
+from core.domain.thread_goal import GoalOutcome, ThreadGoal
+from core.mcp.oauth import McpOAuthFlowInfo
+from core.mcp.probe import McpProbeResult
+from core.providers.reasoning import ModelReasoningCapabilities
+
+
+class ServiceLLM:
+ def __init__(self, rpc):
+ self.rpc = rpc
+
+ def list_connections(self, project_id=None):
+ return self.rpc.call("provider/list", {"projectId": project_id})
+
+ def list_models(self, connection_id, *, project_id=None):
+ return self.rpc.call(
+ "model/list", {"projectId": project_id, "connectionId": connection_id}
+ )
+
+ def model_reasoning(self, connection_id, model, *, project_id=None):
+ value = self.rpc.call(
+ "model/reasoning",
+ {"projectId": project_id, "connectionId": connection_id, "model": model},
+ )["reasoning"]
+ return ModelReasoningCapabilities.from_dict(value)
+
+
+class ServiceSkills:
+ def __init__(self, rpc):
+ self.rpc = rpc
+
+ def list(self, project_id):
+ return from_view(
+ SkillDiscovery, self.rpc.call("skills/list", {"projectId": project_id})
+ )
+
+ def select(self, project_id, identifier):
+ value = self.rpc.call(
+ "skill/read", {"projectId": project_id, "name": identifier}
+ )["skill"]
+ skill = from_view(SkillInfo, value)
+ if not skill.selectable:
+ raise InvalidArgumentError(f"Skill {skill.name} is not selectable")
+ return skill
+
+
+class ServicePlugins:
+ def __init__(self, rpc):
+ self.rpc = rpc
+
+ def list(self):
+ return from_view(PluginDiscovery, self.rpc.call("plugins/list", {}))
+
+
+class ServiceMCP:
+ def __init__(self, rpc):
+ self.rpc = rpc
+
+ def list(self, project_id=None):
+ return from_view(
+ McpInventory, self.rpc.call("mcp/list", {"projectId": project_id})
+ )
+
+ def list_presets(self, project_id=None):
+ return from_view(
+ McpPresetInventory, self.rpc.call("mcp/presets", {"projectId": project_id})
+ )
+
+ def add_preset(self, preset_id, *, project_id=None):
+ return self.rpc.call(
+ "mcp/preset/add", {"projectId": project_id, "presetId": preset_id}
+ )
+
+ def probe(self, name, *, project_id=None):
+ return from_view(
+ McpProbeResult,
+ self.rpc.call("mcp/probe", {"projectId": project_id, "name": name}),
+ )
+
+ def oauth_start(self, name, *, project_id=None, open_browser=True):
+ return from_view(
+ McpOAuthFlowInfo,
+ self.rpc.call(
+ "mcp/oauth/start",
+ {"projectId": project_id, "name": name, "openBrowser": open_browser},
+ ),
+ )
+
+ def oauth_logout(self, name, *, project_id=None):
+ return self.rpc.call(
+ "mcp/oauth/logout", {"projectId": project_id, "name": name}
+ )["removed"]
+
+ def oauth_cancel(self, name, *, project_id=None):
+ return self.rpc.call(
+ "mcp/oauth/cancel", {"projectId": project_id, "name": name}
+ )["cancelled"]
+
+ def set_enabled(self, name, *, enabled, project_id=None):
+ return self.rpc.call(
+ "mcp/set-enabled",
+ {"projectId": project_id, "name": name, "enabled": enabled},
+ )
+
+ def remove(self, *, name, scope, project_id=None):
+ return self.rpc.call(
+ "mcp/remove", {"projectId": project_id, "name": name, "scope": scope}
+ )
+
+
+class ServiceGoals:
+ def __init__(self, rpc):
+ self.rpc = rpc
+
+ def read(self, thread_id):
+ value = self.rpc.call("thread/goal/get", {"threadId": thread_id})["goal"]
+ return from_view(ThreadGoal, value) if value else None
+
+ def read_outcome(self, thread_id):
+ value = self.rpc.call("thread/goal/get", {"threadId": thread_id})["outcome"]
+ return from_view(GoalOutcome, value) if value else None
+
+ def pause(self, thread_id, *, expected_goal_id):
+ value = self.rpc.call(
+ "thread/goal/pause",
+ {"threadId": thread_id, "expectedGoalId": expected_goal_id},
+ )
+ return from_view(ThreadGoal, value["goal"])
+
+ def resume(
+ self,
+ thread_id,
+ *,
+ expected_goal_id,
+ client_surface=None,
+ connection_id=None,
+ model=None,
+ reasoning_effort=None,
+ ):
+ value = self.rpc.call(
+ "thread/goal/resume",
+ {
+ "threadId": thread_id,
+ "expectedGoalId": expected_goal_id,
+ **(
+ {"connectionId": connection_id} if connection_id is not None else {}
+ ),
+ **({"model": model} if model is not None else {}),
+ **(
+ {"reasoningEffort": reasoning_effort}
+ if reasoning_effort is not None
+ else {}
+ ),
+ },
+ )
+ return from_view(ThreadGoal, value["goal"])
+
+ def continue_goal(
+ self,
+ thread_id,
+ *,
+ expected_goal_id,
+ client_surface=None,
+ connection_id=None,
+ model=None,
+ reasoning_effort=None,
+ ):
+ return from_view(
+ GoalContinueResult,
+ self.rpc.call(
+ "thread/goal/continue",
+ {
+ "threadId": thread_id,
+ "expectedGoalId": expected_goal_id,
+ **(
+ {"connectionId": connection_id}
+ if connection_id is not None
+ else {}
+ ),
+ **({"model": model} if model is not None else {}),
+ **(
+ {"reasoningEffort": reasoning_effort}
+ if reasoning_effort is not None
+ else {}
+ ),
+ },
+ ),
+ )
+
+ def clear(self, thread_id, *, expected_goal_id):
+ self.rpc.call(
+ "thread/goal/clear",
+ {"threadId": thread_id, "expectedGoalId": expected_goal_id},
+ )
+
+ def create(
+ self,
+ thread_id,
+ *,
+ objective,
+ skill_ids=(),
+ start=True,
+ client_surface=None,
+ token_budget=None,
+ ):
+ value = self.rpc.call(
+ "thread/goal/set",
+ {
+ "threadId": thread_id,
+ "objective": objective,
+ "tokenBudget": token_budget,
+ "skills": list(skill_ids),
+ "start": start,
+ },
+ )
+ return from_view(ThreadGoal, value["goal"])
+
+ def edit(
+ self,
+ thread_id,
+ *,
+ expected_goal_id,
+ objective,
+ token_budget,
+ skill_ids,
+ continue_work,
+ client_surface=None,
+ ):
+ value = self.rpc.call(
+ "thread/goal/set",
+ {
+ "threadId": thread_id,
+ "expectedGoalId": expected_goal_id,
+ "objective": objective,
+ "tokenBudget": token_budget,
+ "skills": list(skill_ids),
+ "start": continue_work,
+ },
+ )
+ return from_view(ThreadGoal, value["goal"])
diff --git a/cli/service_cli.py b/cli/service_cli.py
new file mode 100644
index 000000000..f5d92d218
--- /dev/null
+++ b/cli/service_cli.py
@@ -0,0 +1,465 @@
+"""Manage the local service without opening another execution runtime."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from app_server.service_client import (
+ ServiceClient,
+ ServiceOperationError,
+ ServiceUnavailable,
+)
+from app_server.service_state import (
+ ServiceFiles,
+ service_command,
+ service_working_directory,
+)
+from core.file_lock import exclusive_file_lock
+from core.persistence.database import default_database_path
+from core.private_storage import open_existing_private_file
+
+
+def _service_manager(files: ServiceFiles):
+ if sys.platform == "darwin":
+ from app_server.launchd import LaunchAgent
+
+ return LaunchAgent(files)
+ if sys.platform == "linux":
+ from app_server.systemd_user import SystemdUserService
+
+ return SystemdUserService(files)
+ if sys.platform == "win32":
+ from app_server.windows_task import WindowsUserTask
+
+ return WindowsUserTask(files)
+ return None
+
+
+def start_service(
+ files: ServiceFiles, *, port: int | None = None, timeout: float = 30.0
+) -> dict:
+ with exclusive_file_lock(files.directory / "management.lock"):
+ return _start_service(files, port=port, timeout=timeout)
+
+
+def _start_service(files: ServiceFiles, *, port: int | None, timeout: float) -> dict:
+ if files.running():
+ status = _wait_ready(
+ ServiceClient(files), timeout, port=port, existing_only=True
+ )
+ if status is not None:
+ return status
+ agent = _service_manager(files)
+ if agent is not None and agent.path.exists():
+ agent.start(port=port)
+ try:
+ return _wait_ready(ServiceClient(files), timeout, port=port)
+ except BaseException:
+ agent.unload()
+ raise
+ return _start_detached(files, port=port, timeout=timeout)
+
+
+def stop_service(files: ServiceFiles, *, timeout: float, cancel_running: bool) -> dict:
+ with exclusive_file_lock(files.directory / "management.lock"):
+ return _stop_service(files, timeout=timeout, cancel_running=cancel_running)
+
+
+def _stop_service(files: ServiceFiles, *, timeout: float, cancel_running: bool) -> dict:
+ agent = _service_manager(files)
+ job = agent.job() if agent is not None else {"loaded": False}
+ if job["loaded"]:
+ discovered = files.read() if files.running() else None
+ if files.running() and discovered is None:
+ raise ServiceUnavailable("Service is starting; retry stop when it is ready")
+ record = discovered[0] if discovered is not None else None
+ client = ServiceClient(files)
+ if record is not None:
+ if job["pid"] not in (None, record.pid):
+ raise ServiceOperationError(
+ "Service manager PID does not match the running service; inspect service doctor"
+ )
+ if not cancel_running:
+ client.call(
+ "drain",
+ {"timeout": timeout},
+ timeout=timeout + 10,
+ instance_id=record.instance_id,
+ )
+ try:
+ agent.unload()
+ except BaseException:
+ if record is not None and not cancel_running:
+ client.call("resume", instance_id=record.instance_id)
+ raise
+ if record is not None:
+ if job["pid"] is None:
+ # An idle job may coexist with a manual service. Drain that
+ # service before unloading too: launchd can start between queries.
+ try:
+ client.call(
+ "stop",
+ {"timeout": timeout, "cancelRunning": cancel_running},
+ timeout=timeout + 10,
+ instance_id=record.instance_id,
+ )
+ except (ServiceUnavailable, ServiceOperationError):
+ # A launchd child may already be shutting down after bootout.
+ # The bounded identity-aware wait below still verifies exit.
+ pass
+ return _wait_stopped(files, record.instance_id, timeout=35)
+ return _stop_detached(files, timeout=timeout, cancel_running=cancel_running)
+
+
+def _start_detached(
+ files: ServiceFiles, *, port: int | None = None, timeout: float = 30.0
+) -> dict:
+ client = ServiceClient(files)
+ command = service_command(files, 3081 if port is None else port)
+ process = subprocess.Popen(
+ command,
+ cwd=service_working_directory(command),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=os.name != "nt",
+ creationflags=(
+ subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
+ )
+ if os.name == "nt"
+ else 0,
+ )
+ try:
+ result = _wait_ready(client, timeout, process=process, port=port)
+ if result["pid"] != process.pid:
+ # Another concurrent launcher won. Reap our losing child before
+ # returning, so it cannot become a replacement after the winner stops.
+ _stop_startup_child(process)
+ return result
+ except BaseException:
+ _stop_startup_child(process)
+ raise
+
+
+def _stop_startup_child(process: subprocess.Popen) -> None:
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=5)
+
+
+def _wait_ready(
+ client: ServiceClient,
+ timeout: float,
+ *,
+ process=None,
+ port=None,
+ existing_only=False,
+) -> dict | None:
+ deadline = time.monotonic() + timeout
+ last_failure = "No service probe completed"
+ while True:
+ try:
+ status = client.call("status", timeout=1)
+ if port not in (None, 0) and status["url"] != f"http://127.0.0.1:{port}":
+ raise ServiceOperationError(
+ f"Service already runs at {status['url']}; stop it before changing ports"
+ )
+ if status["phase"] == "drained":
+ client.call("resume", instance_id=status["instanceId"])
+ continue
+ if status["phase"] == "ready":
+ return status
+ except ServiceUnavailable as exc:
+ last_failure = str(exc)
+ # A status probe can hold the lifetime lock briefly. If that apparent
+ # owner exits, the caller must launch instead of waiting for a phantom.
+ if existing_only and not client.files.running():
+ return None
+ if (
+ process is not None
+ and process.poll() is not None
+ and not client.files.running()
+ ):
+ raise ServiceUnavailable(
+ f"Service startup failed (exit {process.returncode}); inspect {client.files.log}"
+ )
+ if time.monotonic() >= deadline:
+ raise ServiceUnavailable(
+ f"Service did not become ready; inspect {client.files.log}. "
+ f"Last check: {last_failure}"
+ )
+ time.sleep(0.1)
+
+
+def _stop_detached(
+ files: ServiceFiles, *, timeout: float, cancel_running: bool
+) -> dict:
+ if not files.running():
+ return {"phase": "stopped"}
+ stopped = ServiceClient(files).call(
+ "stop",
+ {"timeout": timeout, "cancelRunning": cancel_running},
+ timeout=timeout + 10,
+ )
+ return _wait_stopped(files, stopped["instanceId"], timeout=15)
+
+
+def _wait_stopped(files: ServiceFiles, instance_id: str, *, timeout: float) -> dict:
+ deadline = time.monotonic() + timeout
+ while files.running():
+ current = files.read()
+ if current is not None and current[0].instance_id != instance_id:
+ raise ServiceOperationError(
+ "The stopped service has been replaced by another instance; the replacement was not stopped"
+ )
+ if time.monotonic() >= deadline:
+ raise ServiceUnavailable(
+ "Service accepted stop but cleanup has not finished; inspect service logs"
+ )
+ time.sleep(0.05)
+ return {"phase": "stopped"}
+
+
+def _logs(files: ServiceFiles, *, lines: int, follow: bool) -> None:
+ offset = 0
+ identity = None
+ first = True
+ while True:
+ try:
+ with os.fdopen(open_existing_private_file(files.log), "rb") as stream:
+ info = os.fstat(stream.fileno())
+ current = (info.st_dev, info.st_ino)
+ if first:
+ stream.seek(max(0, info.st_size - 2 * 1024 * 1024))
+ content = stream.read().decode("utf-8", errors="replace")
+ print("\n".join(content.splitlines()[-lines:]), flush=True)
+ else:
+ stream.seek(
+ 0 if current != identity or info.st_size < offset else offset
+ )
+ print(
+ stream.read().decode("utf-8", errors="replace"),
+ end="",
+ flush=True,
+ )
+ offset = stream.tell()
+ identity = current
+ first = False
+ except FileNotFoundError:
+ if not follow:
+ print("No service log yet.")
+ if not follow:
+ return
+ time.sleep(0.25)
+
+
+def run(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="deepcode service",
+ description="Start, inspect, and stop the local DeepCode background service.",
+ )
+ commands = parser.add_subparsers(dest="command", required=True)
+ for name in (
+ "start",
+ "status",
+ "stop",
+ "restart",
+ "logs",
+ "install",
+ "uninstall",
+ "doctor",
+ "snapshot",
+ "prepare-upgrade",
+ "restore",
+ ):
+ command = commands.add_parser(name)
+ command.add_argument("--database", type=Path)
+ command.add_argument("--json", action="store_true")
+ if name in {"start", "restart", "install"}:
+ command.add_argument("--port", type=int)
+ if name in {"snapshot", "prepare-upgrade"}:
+ command.add_argument("--output", type=Path, required=True)
+ if name in {"snapshot", "prepare-upgrade", "restore"}:
+ command.add_argument(
+ "--sessions",
+ type=Path,
+ help="Canonical Session directory for a service without saved layout metadata",
+ )
+ if name == "restore":
+ command.add_argument("--snapshot", type=Path, required=True)
+ command.add_argument(
+ "--replace-data",
+ action="store_true",
+ help="Explicitly replace runtime data with the snapshot; project files are not restored",
+ )
+ if name in {"stop", "restart", "uninstall", "prepare-upgrade"}:
+ mode = command.add_mutually_exclusive_group()
+ mode.add_argument(
+ "--drain",
+ action="store_true",
+ help="Wait for running tasks and terminals (default)",
+ )
+ mode.add_argument(
+ "--cancel-running",
+ action="store_true",
+ help="Cancel current service work before stopping",
+ )
+ command.add_argument("--timeout", type=float, default=60.0)
+ if name == "install":
+ command.add_argument(
+ "--at-login",
+ action="store_true",
+ required=True,
+ help="Opt in to starting after operating-system user login",
+ )
+ command.add_argument(
+ "--path",
+ help="PATH for tools in the managed service (default: current PATH)",
+ )
+ if name == "logs":
+ command.add_argument("--lines", type=int, default=100)
+ command.add_argument("--follow", action="store_true")
+ args = parser.parse_args(argv)
+ if getattr(args, "port", None) is not None and not 0 <= args.port <= 65535:
+ parser.error("port must be between 0 and 65535")
+ if hasattr(args, "timeout") and not 0 <= args.timeout <= 300:
+ parser.error("timeout must be between 0 and 300 seconds")
+ files = ServiceFiles(args.database or default_database_path())
+ try:
+ if args.command in {"snapshot", "prepare-upgrade", "restore"}:
+ from app_server.state_backup import (
+ StatePaths,
+ create_snapshot,
+ restore_snapshot,
+ )
+
+ paths = StatePaths.current(files, sessions=args.sessions)
+ if args.command == "prepare-upgrade":
+ stop_service(
+ files, timeout=args.timeout, cancel_running=args.cancel_running
+ )
+ result = (
+ restore_snapshot(paths, args.snapshot, replace_data=args.replace_data)
+ if args.command == "restore"
+ else create_snapshot(paths, args.output)
+ )
+ print(
+ json.dumps(result, ensure_ascii=False, indent=None if args.json else 2)
+ )
+ return 0
+ if args.command == "logs":
+ if not 1 <= args.lines <= 10_000:
+ parser.error("lines must be between 1 and 10000")
+ _logs(files, lines=args.lines, follow=args.follow)
+ return 0
+ if args.command in {"install", "uninstall", "doctor"}:
+ agent = _service_manager(files)
+ if agent is None:
+ raise ServiceOperationError(
+ "No user service manager is available on this platform"
+ )
+ with exclusive_file_lock(files.directory / "management.lock"):
+ if args.command == "install":
+ result = agent.install(
+ port=3081 if args.port is None else args.port, path=args.path
+ )
+ elif args.command == "uninstall":
+ if agent.job()["loaded"]:
+ _stop_service(
+ files,
+ timeout=args.timeout,
+ cancel_running=args.cancel_running,
+ )
+ result = agent.uninstall()
+ else:
+ result = agent.doctor()
+ if args.json:
+ print(json.dumps(result, ensure_ascii=False))
+ else:
+ print(
+ f"{agent.name}: {'installed' if result['installed'] else 'not installed'}"
+ )
+ print(f"File: {result['path']}")
+ if args.command == "install":
+ print(
+ "Starts with the user session. Run 'deepcode service start' to start now."
+ )
+ if args.command == "doctor":
+ for check in result["checks"]:
+ print(
+ f" {check['name']}: {'ok' if check['ok'] else 'needs attention'}"
+ )
+ print(
+ f"User session available: {result.get('sessionAvailable', result.get('guiSessionAvailable', False))} · job loaded: {result['loaded']}"
+ )
+ if result["shellOnlyVariables"]:
+ print(
+ "Shell-only variables are not copied into the service manager: "
+ + ", ".join(result["shellOnlyVariables"])
+ )
+ return (
+ 1
+ if args.command == "doctor"
+ and any(not check["ok"] for check in result["checks"])
+ else 0
+ )
+ if args.command == "status":
+ result = (
+ ServiceClient(files).call("status")
+ if files.running()
+ else {"phase": "stopped"}
+ )
+ agent = _service_manager(files)
+ if agent is not None:
+ result["supervision"] = {
+ "installed": agent.path.exists(),
+ **agent.job(),
+ }
+ elif args.command == "stop":
+ result = stop_service(
+ files, timeout=args.timeout, cancel_running=args.cancel_running
+ )
+ else:
+ with exclusive_file_lock(files.directory / "management.lock"):
+ port = args.port
+ if args.command == "restart":
+ previous = files.read()
+ agent = _service_manager(files)
+ if (
+ port is None
+ and previous is not None
+ and not (agent and agent.path.exists())
+ ):
+ port = previous[0].port
+ _stop_service(
+ files, timeout=args.timeout, cancel_running=args.cancel_running
+ )
+ result = _start_service(files, port=port, timeout=30)
+ if args.json:
+ print(json.dumps(result, ensure_ascii=False))
+ else:
+ print(f"DeepCode service: {result['phase']}")
+ if "url" in result:
+ print(f"Management endpoint: {result['url']} · PID {result['pid']}")
+ print(
+ f"Active turns: {result['activeTurns']} · queued: {result['queuedTurns']} · terminals: {result['terminals']}"
+ )
+ return 0
+ except KeyboardInterrupt:
+ return 130
+ except (ServiceUnavailable, ServiceOperationError, OSError, ValueError) as exc:
+ if args.json:
+ print(json.dumps({"error": str(exc)}))
+ else:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
diff --git a/cli/service_events.py b/cli/service_events.py
new file mode 100644
index 000000000..704d37492
--- /dev/null
+++ b/cli/service_events.py
@@ -0,0 +1,221 @@
+"""Render durable service events through the existing TUI/exec event vocabulary."""
+
+from __future__ import annotations
+
+from cli.rpc_models import from_view
+from core.domain.event import DomainEvent
+from core.events import (
+ AgentMessage,
+ AgentMessageCompleted,
+ AgentMessageDelta,
+ AgentMessagePhase,
+ AgentReasoningCompleted,
+ AgentReasoningDelta,
+ AgentReasoningStarted,
+ ErrorEvent,
+ Event,
+ ModelUsageRecorded,
+ PlanStep,
+ PlanUpdated,
+ SkillLoaded,
+ TaskComplete,
+ ToolActivity,
+ ToolCompleted,
+ ToolStarted,
+ TurnStarted,
+)
+from core.reasoning import ReasoningChannel, ReasoningPayload
+from core.skills.models import SkillInvocation, SkillInvocationKind
+
+
+class ServiceEventRenderer:
+ """Keep only active item identities; the caller owns the contiguous cursor."""
+
+ def __init__(self):
+ self._items = {}
+ self._started = set()
+ self._skills = set()
+
+ def seed(self, items):
+ for item in items:
+ self._remember(
+ item.id,
+ item.kind.value,
+ item.payload.get("messageId") or item.id,
+ item.turn_id,
+ )
+
+ def _remember(self, item_id, kind, message_id, turn_id):
+ if len(self._items) >= 2048 and item_id not in self._items:
+ raise RuntimeError("Too many active timeline items to render")
+ self._items[item_id] = (kind, message_id, turn_id)
+
+ def convert(self, event: DomainEvent):
+ messages = self._messages(event)
+ return [
+ Event(f"{event.id}:{index}", message)
+ for index, message in enumerate(messages)
+ ]
+
+ def _messages(self, event):
+ payload = event.payload
+ turn = payload.get("turn", {})
+ if (
+ event.type in {"turn.started", "turn.updated"}
+ and turn.get("status") in {"running", "queued"}
+ and event.turn_id not in self._started
+ ):
+ self._started.add(event.turn_id)
+ return [TurnStarted()]
+ if event.type in {
+ "turn.completed",
+ "turn.failed",
+ "turn.interrupted",
+ "turn.recovered",
+ }:
+ self._started.discard(event.turn_id)
+ self._skills = {key for key in self._skills if key[0] != event.turn_id}
+ self._items = {
+ key: value
+ for key, value in self._items.items()
+ if value[2] != event.turn_id
+ }
+ return [
+ TaskComplete(
+ None, turn.get("stopReason") or turn.get("status") or "interrupted"
+ )
+ ]
+ if event.type == "turn.usage.recorded":
+ return [
+ ModelUsageRecorded(
+ response_ordinal=payload["responseOrdinal"], usage=payload["usage"]
+ )
+ ]
+ if event.type == "turn.plan.updated":
+ plan = payload.get("plan", {})
+ return [
+ PlanUpdated(
+ tuple(from_view(PlanStep, step) for step in plan.get("steps", [])),
+ plan.get("explanation"),
+ )
+ ]
+ if event.type == "item.delta":
+ state = self._items.get(event.item_id)
+ if state is None:
+ raise RuntimeError("A streamed item is missing its initial state")
+ kind, message_id, _ = state
+ if kind == "assistant_message":
+ return [AgentMessageDelta(payload["delta"], message_id)]
+ if kind == "reasoning_summary":
+ return [
+ AgentReasoningDelta(
+ event.item_id,
+ ReasoningChannel(payload["reasoningChannel"]),
+ payload["delta"],
+ )
+ ]
+ return []
+ if event.type not in {"item.created", "item.updated"}:
+ return []
+ item = payload["item"]
+ kind, data = item["kind"], item["payload"]
+ identity = data.get("messageId") or item["id"]
+ running = item["status"] in {"in_progress", "pending"}
+ created = event.type == "item.created"
+ if running:
+ self._remember(item["id"], kind, identity, event.turn_id)
+ else:
+ self._items.pop(item["id"], None)
+ if kind == "user_message":
+ loaded = []
+ for value in data.get("skills", []):
+ if not isinstance(value, dict):
+ continue
+ key = (event.turn_id, value["skillId"])
+ if key not in self._skills:
+ self._skills.add(key)
+ loaded.append(
+ SkillLoaded(
+ SkillInvocation(
+ value["skillId"],
+ value["name"],
+ value["revision"],
+ value["source"],
+ SkillInvocationKind(value["invocation"]),
+ )
+ )
+ )
+ return loaded
+ if kind == "assistant_message":
+ if running:
+ return (
+ [AgentMessageDelta(data["text"], identity)]
+ if created and data.get("text")
+ else []
+ )
+ phase = AgentMessagePhase(data.get("phase", "final_answer"))
+ if phase is AgentMessagePhase.COMMENTARY:
+ return [
+ AgentMessageCompleted(
+ identity, data.get("text", item["summary"]), phase
+ )
+ ]
+ return [AgentMessage(data.get("text", item["summary"]), identity, phase)]
+ if kind == "reasoning_summary":
+ reasoning = ReasoningPayload.from_dict(data)
+ if running:
+ if not created:
+ return []
+ messages = [AgentReasoningStarted(item["id"], reasoning.effort)]
+ if reasoning.summary_text:
+ messages.append(
+ AgentReasoningDelta(
+ item["id"], ReasoningChannel.SUMMARY, reasoning.summary_text
+ )
+ )
+ if reasoning.trace_text:
+ messages.append(
+ AgentReasoningDelta(
+ item["id"],
+ ReasoningChannel.PROVIDER_TRACE,
+ reasoning.trace_text,
+ )
+ )
+ return messages
+ return [
+ AgentReasoningCompleted(
+ item["id"],
+ reasoning.summary_text,
+ reasoning.trace_text,
+ reasoning.availability,
+ reasoning.effort,
+ reasoning.duration_ms,
+ )
+ ]
+ if (
+ kind in {"tool_call", "command_execution", "file_change", "test_result"}
+ and "callId" in data
+ ):
+ if running and created:
+ return [
+ ToolStarted(
+ data["callId"],
+ data["name"],
+ data.get("detail", ""),
+ from_view(ToolActivity, data["activity"])
+ if data.get("activity")
+ else None,
+ )
+ ]
+ if not running:
+ return [
+ ToolCompleted(
+ data["callId"],
+ data["name"],
+ data.get("isError", item["status"] != "completed"),
+ data.get("resultPreview", ""),
+ )
+ ]
+ if kind == "error":
+ return [ErrorEvent(data.get("message") or item["summary"])]
+ return []
diff --git a/cli/service_thread_client.py b/cli/service_thread_client.py
new file mode 100644
index 000000000..c6b2cd0fd
--- /dev/null
+++ b/cli/service_thread_client.py
@@ -0,0 +1,562 @@
+"""TUI attachment using the shared service, with no local execution owner."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+from app_server.blocking_client import BlockingServiceClient
+from app_server.service_state import ServiceFiles
+from cli.rpc_models import from_view
+from cli.service_catalogs import (
+ ServiceGoals,
+ ServiceLLM,
+ ServiceMCP,
+ ServicePlugins,
+ ServiceSkills,
+)
+from cli.service_events import ServiceEventRenderer
+from cli.service_turns import ServiceTurns
+from cli.thread_client import ThreadListing, TurnDelivery, turn_access_summary
+from core.application.errors import ProjectNotTrustedError
+from core.application.interactive_turn_router import InteractiveTurnRouter
+from core.domain.approval import Approval, ApprovalStatus
+from core.domain.common import new_id
+from core.domain.event import DomainEvent
+from core.domain.execution_profile import ExecutionProfile
+from core.domain.execution_security import ExecutionSecurityProfile
+from core.domain.message_provenance import ClientSurface
+from core.domain.project import Project, TrustState
+from core.domain.thread import Thread
+from core.events import ErrorEvent, Event
+from core.persistence.database import default_database_path
+from core.sessions import SessionStore
+
+
+class ServiceThreadClient:
+ runtime_mode = "service"
+
+ def __init__(
+ self,
+ *,
+ workspace,
+ model,
+ connection_id,
+ reasoning_effort,
+ max_iterations,
+ streaming,
+ trust_workspace=False,
+ resume_id=None,
+ store=None,
+ event_sink=None,
+ database=None,
+ surface="cli",
+ ):
+ if max_iterations is not None:
+ raise ValueError("--max-iterations is not supported by the shared service")
+ self.workspace = str(Path(workspace or Path.cwd()).expanduser().resolve())
+ self._event_sink = event_sink
+ self._domain_task = None
+ self._renderer = ServiceEventRenderer()
+ self._domain_sink = None
+ self._selection_generation = 0
+ self._sequence = 0
+ self.rpc = BlockingServiceClient(
+ ServiceFiles(database or default_database_path()), surface=surface
+ )
+ try:
+ self.llm = ServiceLLM(self.rpc)
+ self.skills = ServiceSkills(self.rpc)
+ self.plugins = ServicePlugins(self.rpc)
+ self.mcp = ServiceMCP(self.rpc)
+ self.goals = ServiceGoals(self.rpc)
+ self.turns = ServiceTurns(self.rpc)
+ self.router = InteractiveTurnRouter(
+ self.turns, client_surface=ClientSurface.CLI
+ )
+ diagnostics = self.rpc.call("diagnostics/read", {})["diagnostics"]
+ self.store = store or SessionStore(Path(diagnostics["sessionStorePath"]))
+ if resume_id is None:
+ project = self.rpc.call(
+ "project/add",
+ {
+ "path": self.workspace,
+ "trustState": "trusted" if trust_workspace else "untrusted",
+ },
+ )["project"]
+ self.project = from_view(Project, project)
+ if trust_workspace and not self.project_trusted:
+ self.project = from_view(
+ Project,
+ self.rpc.call(
+ "project/update",
+ {"projectId": self.project.id, "trustState": "trusted"},
+ )["project"],
+ )
+ self._require_trust()
+ value = self.rpc.call(
+ "thread/start",
+ {
+ "projectId": self.project.id,
+ "title": "New task",
+ "connectionId": connection_id,
+ "model": model,
+ "reasoningEffort": reasoning_effort,
+ },
+ )
+ self._replace_thread(from_view(Thread, value["thread"]))
+ else:
+ existing = from_view(
+ Thread,
+ self.rpc.call("thread/read", {"threadId": resume_id})["thread"],
+ )
+ if workspace is None:
+ self.workspace = existing.workspace_path
+ project = (
+ self.rpc.call("project/add", {"path": self.workspace})["project"]
+ if workspace is not None
+ else self.rpc.call(
+ "project/read", {"projectId": existing.project_id}
+ )["project"]
+ )
+ self.project = from_view(Project, project)
+ if trust_workspace:
+ self.project = from_view(
+ Project,
+ self.rpc.call(
+ "project/update",
+ {"projectId": self.project.id, "trustState": "trusted"},
+ )["project"],
+ )
+ self._require_trust()
+ value = self.rpc.call(
+ "thread/resume",
+ {"sessionId": resume_id, "workspacePath": self.workspace},
+ )
+ self._replace_thread(from_view(Thread, value["thread"]))
+ if surface != "headless" and (
+ model is not None
+ or connection_id is not None
+ or reasoning_effort is not None
+ ):
+ self.switch_execution(
+ connection_id=connection_id or self.thread.connection_id,
+ model=model or self.thread.model,
+ reasoning_effort=reasoning_effort
+ if reasoning_effort is not None
+ else self.thread.reasoning_effort,
+ context_window=self.thread.context_window,
+ )
+ except BaseException:
+ self.rpc.close()
+ raise
+
+ @property
+ def session_id(self):
+ return self.thread.id
+
+ @property
+ def model(self):
+ return self.execution_profile.model_id
+
+ @property
+ def project_trusted(self):
+ return self.project.trust_state is TrustState.TRUSTED
+
+ @property
+ def access_preset_override(self):
+ return self.thread.access_preset_override
+
+ def _require_trust(self):
+ if not self.project_trusted:
+ raise ProjectNotTrustedError(
+ "Project is untrusted; inspect it and use --trust explicitly"
+ )
+
+ def _replace_thread(self, thread):
+ project = from_view(
+ Project,
+ self.rpc.call("project/read", {"projectId": thread.project_id})["project"],
+ )
+ if project.trust_state is not TrustState.TRUSTED:
+ raise ProjectNotTrustedError(
+ "Project is untrusted; inspect it and use --trust explicitly"
+ )
+ profile = self.rpc.call("thread/execution/read", {"threadId": thread.id})
+ execution = ExecutionProfile.from_dict(profile["executionProfile"])
+ security = ExecutionSecurityProfile.from_dict(profile["securityProfile"])
+ if execution is None or security is None:
+ raise ValueError("Service returned an invalid execution profile")
+ sequence = self.rpc.call("event/replay", {"threadId": thread.id, "limit": 1})[
+ "headSequence"
+ ]
+ renderer = ServiceEventRenderer()
+ active = self.turns.executing_for_thread(thread.id)
+ if active:
+ renderer.seed(self.turns.read(active.id).items)
+ self.thread, self.project = thread, project
+ self.execution_profile, self._security = execution, security
+ self._sequence, self._renderer = sequence, renderer
+ self._selection_generation += 1
+
+ def access_summary(self):
+ override = self.thread.access_preset_override
+ if override is not None:
+ return override.value.replace("_", " ")
+ profile = self._security
+ if profile.access_preset is not None:
+ return f"default ({profile.access_preset.value.replace('_', ' ')})"
+ return f"legacy ({profile.permission_mode.value})"
+
+ def frozen_access_summaries(self):
+ turns = self.turns.list_for_thread(self.thread.id)
+ current = next(
+ (
+ turn
+ for turn in turns
+ if turn.status.value in {"running", "waiting_approval"}
+ ),
+ None,
+ )
+ return turn_access_summary(current) if current else None, tuple(
+ turn_access_summary(turn) for turn in turns if turn.status.value == "queued"
+ )
+
+ def send(self, prompt, *, skill_ids=()):
+ active = self.turns.executing_for_thread(self.thread.id)
+ result = self.router.send(
+ self.thread.id,
+ prompt=prompt,
+ message_id=new_id("tinp"),
+ cached_active_turn_id=active.id if active else None,
+ skill_ids=skill_ids,
+ )
+ if result.delivery.value in {"started", "queued"}:
+ self._title_from_first_prompt(prompt)
+ return TurnDelivery(result.delivery.value, result.turn)
+
+ def queue(self, prompt, *, skill_ids=()):
+ snapshot = self.turns.enqueue(
+ self.thread.id,
+ prompt=prompt,
+ message_id=new_id("tinp"),
+ skill_ids=skill_ids,
+ )
+ self._title_from_first_prompt(prompt)
+ return TurnDelivery("queued", snapshot.turn)
+
+ def has_active_turn(self):
+ return self.turns.active_for_thread(self.thread.id) is not None
+
+ def rename_thread(self, title):
+ self.thread = from_view(
+ Thread,
+ self.rpc.call(
+ "thread/rename", {"threadId": self.thread.id, "title": title}
+ )["thread"],
+ )
+ return self.thread
+
+ def delete_session(self, session_id):
+ if session_id == self.thread.id:
+ raise RuntimeError("cannot delete the current Session; /new first")
+ self.rpc.call("thread/delete", {"threadId": session_id})
+
+ def last_terminal_turn(self):
+ return next(
+ (
+ turn
+ for turn in reversed(self.turns.list_for_thread(self.thread.id))
+ if turn.status.is_terminal
+ ),
+ None,
+ )
+
+ def retry_turn(self, turn_id):
+ return self.turns.retry(turn_id, use_current_selection=True).turn
+
+ def interrupt(self):
+ active = self.turns.active_for_thread(self.thread.id)
+ return self.turns.interrupt(self.thread.id, active.id) if active else None
+
+ def pending_approval(self):
+ active = self.turns.executing_for_thread(self.thread.id)
+ if active is None:
+ return None
+ return next(
+ (
+ approval
+ for approval in self.turns.read(active.id).approvals
+ if approval.status is ApprovalStatus.PENDING
+ ),
+ None,
+ )
+
+ def respond_to_approval(self, approval_id, decision):
+ return from_view(
+ Approval,
+ self.rpc.call(
+ "approval/respond",
+ {"approvalId": approval_id, "decision": decision.value},
+ )["approval"],
+ )
+
+ async def wait_until_idle(self):
+ while await asyncio.to_thread(self.has_active_turn):
+ await asyncio.sleep(0.05)
+ await self.drain_events_async()
+ await asyncio.sleep(0)
+
+ def _require_idle(self):
+ if self.has_active_turn():
+ raise RuntimeError(
+ "the current Turn is still active; stop it before changing Session"
+ )
+
+ def new_thread(self, *, title=""):
+ self._require_idle()
+ value = self.rpc.call(
+ "thread/start",
+ {
+ "projectId": self.project.id,
+ "title": title.strip() or "New task",
+ "connectionId": self.execution_profile.connection_id,
+ "model": self.execution_profile.model_id,
+ "reasoningEffort": self.thread.reasoning_effort,
+ "contextWindow": self.thread.context_window,
+ },
+ )
+ self._replace_thread(from_view(Thread, value["thread"]))
+ return self.thread
+
+ def resume(self, session_id):
+ self._require_idle()
+ existing = from_view(
+ Thread, self.rpc.call("thread/read", {"threadId": session_id})["thread"]
+ )
+ project = from_view(
+ Project,
+ self.rpc.call("project/read", {"projectId": existing.project_id})[
+ "project"
+ ],
+ )
+ if project.trust_state is not TrustState.TRUSTED:
+ raise ProjectNotTrustedError("Project is untrusted")
+ value = self.rpc.call(
+ "thread/resume", {"sessionId": session_id, "workspacePath": self.workspace}
+ )
+ self._replace_thread(from_view(Thread, value["thread"]))
+ self._require_trust()
+ return self.thread
+
+ def list_recent(self, *, limit, include_all):
+ result = self.rpc.call(
+ "thread/list",
+ {"cwd": None if include_all else self.workspace, "limit": limit},
+ )
+ listings = []
+ for value in result["threads"]:
+ thread = from_view(Thread, value)
+ session = self.store.get_session(thread.id)
+ if session and session.messages:
+ listings.append(
+ ThreadListing(
+ thread.id,
+ thread.title,
+ len(session.messages),
+ thread.updated_at,
+ thread.workspace_path,
+ thread.id == self.thread.id,
+ )
+ )
+ return listings
+
+ def switch_execution(
+ self, *, connection_id, model, reasoning_effort, context_window
+ ):
+ value = self.rpc.call(
+ "thread/execution/update",
+ {
+ "threadId": self.thread.id,
+ "connectionId": connection_id,
+ "model": model,
+ "reasoningEffort": reasoning_effort,
+ "contextWindow": context_window,
+ },
+ )
+ self.thread = from_view(Thread, value["thread"])
+ profile = self.rpc.call("thread/execution/read", {"threadId": self.thread.id})
+ self.execution_profile = ExecutionProfile.from_dict(profile["executionProfile"])
+ return self.execution_profile
+
+ def set_access_preset(self, access_preset):
+ value = self.rpc.call(
+ "thread/permission/update",
+ {
+ "threadId": self.thread.id,
+ "accessPreset": access_preset.value if access_preset else None,
+ "riskAcknowledged": access_preset is not None
+ and access_preset.value == "full_access",
+ },
+ )
+ self.thread = from_view(Thread, value["thread"])
+ return self.thread
+
+ def set_agent_preset(self, preset_id):
+ self.rpc.call(
+ "preset/select", {"threadId": self.thread.id, "agentPreset": preset_id}
+ )
+ return self.refresh_thread()
+
+ def current_agent_preset_id(self):
+ return self.rpc.call("preset/current", {"threadId": self.thread.id})[
+ "agentPreset"
+ ]
+
+ def refresh_thread(self):
+ thread_id = self.thread.id
+ thread = from_view(
+ Thread, self.rpc.call("thread/read", {"threadId": thread_id})["thread"]
+ )
+ profile = self.rpc.call("thread/execution/read", {"threadId": thread_id})
+ execution = ExecutionProfile.from_dict(profile["executionProfile"])
+ security = ExecutionSecurityProfile.from_dict(profile["securityProfile"])
+ if execution is None or security is None:
+ raise ValueError("Service returned an invalid execution profile")
+ if self.thread.id == thread_id:
+ self.thread, self.execution_profile, self._security = (
+ thread,
+ execution,
+ security,
+ )
+ return self.thread
+
+ def clear_context(self):
+ self.rpc.call("thread/context/clear", {"threadId": self.thread.id})
+
+ async def compact_context(self):
+ return await asyncio.to_thread(
+ self.rpc.call, "thread/context/compact", {"threadId": self.thread.id}
+ )
+
+ def set_event_loop(self, loop):
+ # Events are consumed on the TUI loop by the bounded pump below.
+ pass
+
+ def _consume_page(self, page):
+ for value in page["events"]:
+ event = from_view(DomainEvent, value)
+ if event.sequence <= self._sequence:
+ continue
+ if event.sequence != self._sequence + 1:
+ raise RuntimeError("Thread event replay is not contiguous")
+ if self._domain_sink:
+ self._domain_sink(event)
+ for rendered in self._renderer.convert(event):
+ if self._event_sink:
+ self._event_sink(rendered)
+ self._sequence = event.sequence
+
+ def drain_events(self):
+ through = None
+ while True:
+ params = {"threadId": self.thread.id, "after": self._sequence, "limit": 100}
+ if through is not None:
+ params["through"] = through
+ page = self.rpc.call("event/replay", params)
+ through = page["headSequence"]
+ self._consume_page(page)
+ if not page["hasMore"]:
+ return
+
+ async def drain_events_async(self):
+ through = None
+ generation = self._selection_generation
+ while True:
+ params = {"threadId": self.thread.id, "after": self._sequence, "limit": 100}
+ if through is not None:
+ if through <= self._sequence:
+ return
+ params["through"] = through
+ page = await asyncio.to_thread(self.rpc.call, "event/replay", params)
+ if generation != self._selection_generation:
+ return
+ through = page["headSequence"]
+ self._consume_page(page)
+ if not page["hasMore"]:
+ return
+
+ async def start_domain_events(self, sink):
+ if self._domain_task is not None and not self._domain_task.done():
+ return
+ self._domain_sink = sink
+
+ async def pump():
+ through = None
+ failures = 0
+ while True:
+ thread_id, generation = self.thread.id, self._selection_generation
+ params = {"threadId": thread_id, "after": self._sequence, "limit": 100}
+ if through is not None and through > self._sequence:
+ params["through"] = through
+ try:
+ page = await asyncio.to_thread(
+ self.rpc.call, "event/replay", params
+ )
+ failures = 0
+ except (RuntimeError, OSError) as exc:
+ failures += 1
+ if failures == 1 and self._event_sink:
+ self._event_sink(
+ Event(
+ "connection",
+ ErrorEvent(
+ f"Service disconnected: {exc}. Admitted tasks may still be running."
+ ),
+ )
+ )
+ if failures >= 6:
+ if self._event_sink:
+ self._event_sink(
+ Event(
+ "connection",
+ ErrorEvent(
+ "Use /reconnect after the service is available."
+ ),
+ )
+ )
+ return
+ await asyncio.sleep(min(4, 0.25 * 2**failures))
+ continue
+ if generation != self._selection_generation:
+ through = None
+ continue
+ self._consume_page(page)
+ through = page["headSequence"] if page["hasMore"] else None
+ await asyncio.sleep(0.05)
+
+ self._domain_task = asyncio.create_task(pump())
+
+ async def reconnect(self):
+ await self.stop_domain_events()
+ await asyncio.to_thread(self.rpc.reconnect)
+ await self.start_domain_events(self._domain_sink)
+
+ async def stop_domain_events(self):
+ if self._domain_task is not None:
+ self._domain_task.cancel()
+ await asyncio.gather(self._domain_task, return_exceptions=True)
+ self._domain_task = None
+
+ async def close(self):
+ await self.stop_domain_events()
+ await asyncio.to_thread(self.rpc.close)
+
+ def _title_from_first_prompt(self, prompt):
+ if self.thread.title == "New task" and prompt.strip():
+ try:
+ self.rename_thread(prompt.strip().splitlines()[0][:60])
+ except (RuntimeError, OSError):
+ # Admission already succeeded. A presentation write cannot
+ # make the composer report that the submitted input failed.
+ pass
diff --git a/cli/service_turn.py b/cli/service_turn.py
new file mode 100644
index 000000000..230dfe540
--- /dev/null
+++ b/cli/service_turn.py
@@ -0,0 +1,111 @@
+"""Headless service attachment preserving foreground exit and approval behavior."""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+import time
+import threading
+
+from cli.thread_client import HeadlessTurnOptions, HeadlessTurnResult
+from cli.service_thread_client import ServiceThreadClient
+from core.domain.approval import ApprovalStatus
+from core.domain.common import new_id
+
+
+def run_service_turn(
+ options: HeadlessTurnOptions,
+ *,
+ on_event=None,
+ decide_approval=None,
+ detach=False,
+ detach_requested=None,
+) -> HeadlessTurnResult:
+ prompt = options.prompt.strip()
+ if not prompt:
+ raise ValueError("prompt must not be empty")
+ client = ServiceThreadClient(
+ workspace=options.workspace,
+ model=options.model,
+ connection_id=options.connection_id,
+ reasoning_effort=options.reasoning_effort,
+ max_iterations=options.max_iterations,
+ streaming=False,
+ trust_workspace=options.trust_workspace,
+ resume_id=options.resume_id,
+ event_sink=on_event,
+ surface="headless",
+ )
+ interrupted = False
+ try:
+ if options.access_preset is not None:
+ client.set_access_preset(options.access_preset)
+ if options.agent_preset is not None:
+ client.set_agent_preset(options.agent_preset)
+ skill_ids = tuple(
+ client.skills.select(client.project.id, value).id
+ for value in options.skill_identifiers
+ )
+ snapshot = client.turns.start(
+ client.thread.id,
+ prompt=prompt,
+ message_id=new_id("tinp"),
+ skill_ids=skill_ids,
+ connection_id=options.connection_id,
+ model=options.model,
+ reasoning_effort=options.reasoning_effort,
+ )
+ if not detach:
+ handled = set()
+ while not snapshot.turn.status.is_terminal:
+ if detach_requested is not None and detach_requested.is_set():
+ raise InterruptedError(
+ "Client detached; the task continues in the service"
+ )
+ client.drain_events()
+ for approval in snapshot.approvals:
+ if (
+ approval.status is ApprovalStatus.PENDING
+ and approval.id not in handled
+ ):
+ handled.add(approval.id)
+ decision = (
+ decide_approval(approval)
+ if decide_approval
+ else ApprovalStatus.DENIED
+ )
+ client.respond_to_approval(approval.id, decision)
+ time.sleep(0.05)
+ snapshot = client.turns.read(snapshot.turn.id)
+ client.drain_events()
+ return HeadlessTurnResult(snapshot.turn, client.thread.id, client.workspace)
+ except KeyboardInterrupt:
+ interrupted = True
+ try:
+ client.interrupt()
+ except Exception: # transport failure must not replace the user's exit 130
+ print(
+ "Task interruption could not be confirmed; reconnect to inspect its state.",
+ file=sys.stderr,
+ )
+ raise
+ finally:
+ try:
+ asyncio.run(client.close())
+ except Exception:
+ if not interrupted:
+ raise
+ print("The client connection could not finish closing.", file=sys.stderr)
+
+
+async def run_service_turn_async(
+ options: HeadlessTurnOptions, **kwargs
+) -> HeadlessTurnResult:
+ """Cancel the client waiter without cancelling an already admitted Turn."""
+ detached = threading.Event()
+ try:
+ return await asyncio.to_thread(
+ run_service_turn, options, detach_requested=detached, **kwargs
+ )
+ finally:
+ detached.set()
diff --git a/cli/service_turns.py b/cli/service_turns.py
new file mode 100644
index 000000000..ce29ad100
--- /dev/null
+++ b/cli/service_turns.py
@@ -0,0 +1,131 @@
+"""Typed Turn command port; shares the original InteractiveTurnRouter."""
+
+from __future__ import annotations
+
+from cli.rpc_models import from_view
+from core.application.turn_input_service import TurnInputReceipt
+from core.application.turn_service import TurnSnapshot
+from core.domain.turn import Turn
+
+
+class ServiceTurns:
+ def __init__(self, rpc):
+ self.rpc = rpc
+
+ def start(
+ self,
+ thread_id,
+ *,
+ prompt,
+ message_id,
+ skill_ids=(),
+ client_surface=None,
+ event_observer=None,
+ connection_id=None,
+ model=None,
+ reasoning_effort=None,
+ ):
+ return from_view(
+ TurnSnapshot,
+ self.rpc.call(
+ "turn/start",
+ {
+ "threadId": thread_id,
+ "prompt": prompt,
+ "messageId": message_id,
+ "skills": list(skill_ids),
+ **(
+ {"connectionId": connection_id}
+ if connection_id is not None
+ else {}
+ ),
+ **({"model": model} if model is not None else {}),
+ **(
+ {"reasoningEffort": reasoning_effort}
+ if reasoning_effort is not None
+ else {}
+ ),
+ },
+ ),
+ )
+
+ def enqueue(
+ self,
+ thread_id,
+ *,
+ prompt,
+ message_id,
+ skill_ids=(),
+ client_surface=None,
+ event_observer=None,
+ ):
+ return from_view(
+ TurnSnapshot,
+ self.rpc.call(
+ "turn/enqueue",
+ {
+ "threadId": thread_id,
+ "prompt": prompt,
+ "messageId": message_id,
+ "skills": list(skill_ids),
+ },
+ ),
+ )
+
+ def steer(
+ self, thread_id, *, expected_turn_id, prompt, message_id, client_surface=None
+ ):
+ return from_view(
+ TurnInputReceipt,
+ self.rpc.call(
+ "turn/steer",
+ {
+ "threadId": thread_id,
+ "expectedTurnId": expected_turn_id,
+ "prompt": prompt,
+ "messageId": message_id,
+ },
+ ),
+ )
+
+ def read(self, turn_id):
+ return from_view(TurnSnapshot, self.rpc.call("turn/read", {"turnId": turn_id}))
+
+ def list_for_thread(self, thread_id):
+ turns = []
+ while True:
+ page = self.rpc.call(
+ "turn/list", {"threadId": thread_id, "offset": len(turns), "limit": 100}
+ )
+ turns.extend(from_view(Turn, value) for value in page["turns"])
+ if not page["hasMore"]:
+ return turns
+ if not page["turns"]:
+ raise RuntimeError("Turn listing made no progress")
+
+ def active_for_thread(self, thread_id):
+ return self._first(thread_id, "active")
+
+ def executing_for_thread(self, thread_id):
+ return self._first(thread_id, "executing")
+
+ def _first(self, thread_id, state):
+ page = self.rpc.call(
+ "turn/list", {"threadId": thread_id, "state": state, "limit": 1}
+ )
+ return from_view(Turn, page["turns"][0]) if page["turns"] else None
+
+ def interrupt(self, thread_id, turn_id):
+ value = self.rpc.call(
+ "turn/interrupt", {"threadId": thread_id, "turnId": turn_id}
+ )
+ return value["accepted"], from_view(Turn, value["turn"])
+
+ def retry(self, turn_id, *, use_current_selection=False):
+ return from_view(
+ TurnSnapshot,
+ self.rpc.call(
+ "turn/retry",
+ {"turnId": turn_id, "useCurrentSelection": use_current_selection},
+ ),
+ )
diff --git a/cli/thread_client.py b/cli/thread_client.py
new file mode 100644
index 000000000..1c86c6ec3
--- /dev/null
+++ b/cli/thread_client.py
@@ -0,0 +1,133 @@
+"""The command boundary consumed by TUI presentation."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Protocol
+
+from core.domain.approval import Approval, ApprovalStatus
+from core.domain.event import DomainEvent
+from core.domain.execution_profile import ExecutionProfile
+from core.domain.execution_security import ExecutionAccessPreset
+from core.domain.project import Project
+from core.domain.thread import Thread
+from core.domain.turn import Turn
+from core.sessions import SessionStore
+
+
+@dataclass(frozen=True, slots=True)
+class HeadlessTurnOptions:
+ prompt: str
+ workspace: str | None = None
+ resume_id: str | None = None
+ connection_id: str | None = None
+ model: str | None = None
+ reasoning_effort: str | None = None
+ skill_identifiers: tuple[str, ...] = ()
+ max_iterations: int | None = None
+ trust_workspace: bool = False
+ access_preset: ExecutionAccessPreset | None = None
+ agent_preset: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class HeadlessTurnResult:
+ turn: Turn
+ session_id: str
+ workspace: str
+
+
+@dataclass(frozen=True, slots=True)
+class TurnDelivery:
+ kind: str
+ turn: Turn
+
+
+@dataclass(frozen=True, slots=True)
+class ThreadListing:
+ """One row of the resume picker — display data only."""
+
+ session_id: str
+ title: str
+ message_count: int
+ updated_at: datetime
+ workspace: str
+ is_current: bool
+
+
+def turn_access_summary(turn: Turn) -> str:
+ profile = turn.execution_security_profile
+ if profile is not None:
+ if profile.access_preset is not None:
+ return profile.access_preset.value.replace("_", " ")
+ sandbox = "sandboxed" if profile.command_sandbox else "unsandboxed"
+ return f"legacy {profile.permission_mode.value.replace('_', ' ')} · {sandbox}"
+ if turn.execution_permission_mode is not None:
+ return f"legacy {turn.execution_permission_mode.value.replace('_', ' ')}"
+ return "legacy unknown"
+
+
+class ThreadClient(Protocol):
+ """Presentation consumes commands and snapshots, never an execution owner."""
+
+ runtime_mode: str
+ workspace: str
+ thread: Thread
+ project: Project
+ execution_profile: ExecutionProfile
+ store: SessionStore
+ llm: Any
+ skills: Any
+ plugins: Any
+ mcp: Any
+ goals: Any
+
+ @property
+ def session_id(self) -> str: ...
+ @property
+ def project_trusted(self) -> bool: ...
+ @property
+ def access_preset_override(self) -> ExecutionAccessPreset | None: ...
+ def access_summary(self) -> str: ...
+ def frozen_access_summaries(self) -> tuple[str | None, tuple[str, ...]]: ...
+ def send(self, prompt: str, *, skill_ids: tuple[str, ...] = ()) -> Any: ...
+ def queue(self, prompt: str, *, skill_ids: tuple[str, ...] = ()) -> Any: ...
+ def has_active_turn(self) -> bool: ...
+ def rename_thread(self, title: str) -> Thread: ...
+ def delete_session(self, session_id: str) -> None: ...
+ def last_terminal_turn(self) -> Turn | None: ...
+ def retry_turn(self, turn_id: str) -> Turn: ...
+ def interrupt(self) -> tuple[bool, Turn] | None: ...
+ def pending_approval(self) -> Approval | None: ...
+ def respond_to_approval(
+ self, approval_id: str, decision: ApprovalStatus
+ ) -> Approval: ...
+ async def wait_until_idle(self) -> None: ...
+ def new_thread(self, *, title: str = "") -> Thread: ...
+ def resume(self, session_id: str) -> Thread: ...
+ def list_recent(self, *, limit: int, include_all: bool) -> list[Any]: ...
+ def switch_execution(
+ self,
+ *,
+ connection_id: str | None,
+ model: str | None,
+ reasoning_effort: str | None,
+ context_window: int | None,
+ ) -> ExecutionProfile: ...
+ def set_access_preset(
+ self, access_preset: ExecutionAccessPreset | None
+ ) -> Thread: ...
+ def set_agent_preset(self, preset_id: str | None) -> Thread: ...
+ def current_agent_preset_id(self) -> str | None: ...
+ def refresh_thread(self) -> Thread: ...
+ def clear_context(self) -> None: ...
+ async def compact_context(self) -> dict: ...
+ def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None: ...
+ async def start_domain_events(
+ self, sink: Callable[[DomainEvent], None]
+ ) -> None: ...
+ async def stop_domain_events(self) -> None: ...
+ async def close(self) -> None: ...
diff --git a/cli/tui/app.py b/cli/tui/app.py
index 92d86571d..b84bd16d4 100644
--- a/cli/tui/app.py
+++ b/cli/tui/app.py
@@ -44,13 +44,12 @@
require_project_trusted,
set_project_trusted,
)
+from cli.thread_client import ThreadClient
from cli.tui import commands, theme
from cli.tui.goal_controller import TuiGoalController
from cli.tui.input import InputInterrupted, InputReader, expand_file_refs
from cli.tui.renderer import EventRenderer
from cli.tui.session_bridge import SessionBridge
-from cli.tui.thread_client import TuiThreadClient
-from core.application.application import DeepCodeApplication
from core.application.errors import ApplicationError
from core.config import ConfigError
from core.domain.approval import ApprovalStatus
@@ -60,7 +59,6 @@
from core.file_lock import FileLease
from core.providers.reasoning import normalize_reasoning_effort
-
_MODEL_CATALOG_PREVIEW = 4 # models shown per connection in /model
# Sentinel: switch_model keeps the session's effort unless told otherwise.
_KEEP_EFFORT: object = object()
@@ -82,6 +80,7 @@ def __init__(
trust_workspace: bool = False,
access_preset: ExecutionAccessPreset | None = None,
resume_id: str | None = None,
+ shared_service: bool = True,
) -> None:
self.workspace = os.path.abspath(workspace)
self.max_iterations = max_iterations
@@ -94,6 +93,8 @@ def __init__(
activity_probe=self.renderer.is_working,
)
self._exit_requested = False
+ self._external_refresh_task: asyncio.Task | None = None
+ self._external_refresh_dirty = False
self._last_send_delivered = True
self._requested_model = model
self._requested_connection = connection_id
@@ -106,7 +107,15 @@ def __init__(
self.selected_skill_ids: list[str] = []
self._session_activity: FileLease | None = None
self._leased_session_id: str | None = None
- self.thread_client = TuiThreadClient(
+ if shared_service:
+ from cli.service_thread_client import ServiceThreadClient
+
+ client_type = ServiceThreadClient
+ else:
+ from cli.tui.thread_client import TuiThreadClient
+
+ client_type = TuiThreadClient
+ self.thread_client: ThreadClient = client_type(
workspace=self.workspace,
model=self._requested_model,
connection_id=self._requested_connection,
@@ -257,7 +266,7 @@ def reasoning_options(
Turn resolves its capabilities from, so the picker never advertises
a ladder the switch would reject.
"""
- capabilities = self.thread_client.application.llm.model_reasoning(
+ capabilities = self.thread_client.llm.model_reasoning(
connection_id or self.thread_client.execution_profile.connection_id,
model_id,
project_id=self.thread_client.project.id,
@@ -292,9 +301,7 @@ async def switch_context_window(self, context_window: int | None) -> None:
self._requested_context_window = context_window
def connection_views(self) -> list[dict]:
- data = self.thread_client.application.llm.list_connections(
- self.thread_client.project.id
- )
+ data = self.thread_client.llm.list_connections(self.thread_client.project.id)
return list(data.get("connections", []))
def model_overview(self) -> str:
@@ -328,7 +335,7 @@ def model_overview(self) -> str:
extra = len(models) - _MODEL_CATALOG_PREVIEW
catalog = shown + (f", +{extra} more" if extra > 0 else "")
else:
- catalog = "no catalog configured — any model id accepted"
+ catalog = f"browse models with deepcode provider models {view['id']}"
marker = " · current" if view.get("id") == profile.connection_id else ""
lines.append(f" {str(view.get('id', '')):<{width}} {catalog}{marker}")
return "\n".join(lines)
@@ -342,7 +349,7 @@ def connection_model_catalog(self) -> list[tuple[str, list[dict]]]:
offer one directory. A connection whose catalog cannot be read
contributes nothing instead of failing the whole picker.
"""
- llm = self.thread_client.application.llm
+ llm = self.thread_client.llm
catalog: list[tuple[str, list[dict]]] = []
for view in self.connection_views():
if not (view.get("configured") and view.get("enabled")):
@@ -479,7 +486,7 @@ def set_agent_preset(self, preset_id: str | None) -> str:
def list_skills(self) -> str:
try:
- skills = self.thread_client.application.skills.list(
+ skills = self.thread_client.skills.list(
self.thread_client.project.id
).skills
except (ApplicationError, OSError, ValueError) as exc:
@@ -495,7 +502,7 @@ def list_skills(self) -> str:
def select_skill(self, identifier: str) -> str:
try:
- skill = self.thread_client.application.skills.select(
+ skill = self.thread_client.skills.select(
self.thread_client.project.id,
identifier,
)
@@ -511,7 +518,7 @@ def select_skill(self, identifier: str) -> str:
def remove_skill(self, identifier: str) -> str:
try:
- skills = self.thread_client.application.skills.list(
+ skills = self.thread_client.skills.list(
self.thread_client.project.id
).skills
except (ApplicationError, OSError, ValueError) as exc:
@@ -532,9 +539,7 @@ def remove_skill(self, identifier: str) -> str:
return f"removed {skill.name} from the next turn"
def _completion_skills(self):
- return self.thread_client.application.skills.list(
- self.thread_client.project.id
- ).skills
+ return self.thread_client.skills.list(self.thread_client.project.id).skills
def _complete_command_argument(self, name: str, prefix: str) -> tuple[str, ...]:
command = commands.REGISTRY.get(name)
@@ -551,7 +556,7 @@ def clear_skills(self) -> str:
def list_plugins(self) -> str:
try:
- discovery = self.thread_client.application.plugins.list()
+ discovery = self.thread_client.plugins.list()
except (ApplicationError, OSError, ValueError) as exc:
return f"Plugin error: {exc}"
if not discovery.plugins:
@@ -570,9 +575,7 @@ def list_plugins(self) -> str:
def list_mcp_servers(self) -> str:
try:
- inventory = self.thread_client.application.mcp.list(
- self.thread_client.project.id
- )
+ inventory = self.thread_client.mcp.list(self.thread_client.project.id)
except (ApplicationError, OSError, ValueError) as exc:
return f"MCP error: {exc}"
if not inventory.servers:
@@ -601,7 +604,7 @@ async def manage_mcp(self, args: str) -> str:
return usage
try:
if action == "presets":
- presets = self.thread_client.application.mcp.list_presets(project_id)
+ presets = self.thread_client.mcp.list_presets(project_id)
lines = ["", "Bundled MCP presets (add with /mcp add ):"]
for preset in presets.presets:
status = "configured" if preset.configured else "available"
@@ -618,7 +621,7 @@ async def manage_mcp(self, args: str) -> str:
return usage
if action == "add":
await asyncio.to_thread(
- self.thread_client.application.mcp.add_preset,
+ self.thread_client.mcp.add_preset,
target,
project_id=project_id,
)
@@ -628,7 +631,7 @@ async def manage_mcp(self, args: str) -> str:
)
if action == "test":
result = await asyncio.to_thread(
- self.thread_client.application.mcp.probe,
+ self.thread_client.mcp.probe,
target,
project_id=project_id,
)
@@ -640,7 +643,7 @@ async def manage_mcp(self, args: str) -> str:
)
if action == "login":
flow = await asyncio.to_thread(
- self.thread_client.application.mcp.oauth_start,
+ self.thread_client.mcp.oauth_start,
target,
project_id=project_id,
open_browser=True,
@@ -655,7 +658,7 @@ async def manage_mcp(self, args: str) -> str:
return f"Browser authorization started for {target}.{suffix}"
if action == "logout":
removed = await asyncio.to_thread(
- self.thread_client.application.mcp.oauth_logout,
+ self.thread_client.mcp.oauth_logout,
target,
project_id=project_id,
)
@@ -666,7 +669,7 @@ async def manage_mcp(self, args: str) -> str:
)
if action == "cancel":
await asyncio.to_thread(
- self.thread_client.application.mcp.oauth_cancel,
+ self.thread_client.mcp.oauth_cancel,
target,
project_id=project_id,
)
@@ -675,13 +678,13 @@ async def manage_mcp(self, args: str) -> str:
if action != "remove":
enabled = action == "enable"
await asyncio.to_thread(
- self.thread_client.application.mcp.set_enabled,
+ self.thread_client.mcp.set_enabled,
target,
enabled=enabled,
project_id=project_id,
)
return f"{'enabled' if enabled else 'disabled'} MCP server {target}"
- inventory = self.thread_client.application.mcp.list(project_id)
+ inventory = self.thread_client.mcp.list(project_id)
matches = [
server
for server in inventory.servers
@@ -694,7 +697,7 @@ async def manage_mcp(self, args: str) -> str:
return "Plugin MCP servers are managed through /plugins"
scope = "project" if server.source == "project" else "user"
await asyncio.to_thread(
- self.thread_client.application.mcp.remove,
+ self.thread_client.mcp.remove,
name=server.name,
scope=scope,
project_id=project_id,
@@ -711,9 +714,18 @@ async def run_goal_command(self, args: str) -> str:
return result.message
async def _reload_current_session(self) -> None:
- self.thread_client.refresh_thread()
+ await asyncio.to_thread(self.thread_client.refresh_thread)
self._sync_thread_state()
+ async def reconnect_service(self) -> str:
+ if self.thread_client.runtime_mode != "service":
+ return "This connection does not support reconnection."
+ try:
+ await self.thread_client.reconnect()
+ return "Reconnected to the shared service."
+ except (RuntimeError, OSError) as exc:
+ return f"Reconnect failed: {exc}"
+
def request_exit(self) -> None:
self._exit_requested = True
@@ -807,6 +819,31 @@ def _respond_to_pending_approval(self, text: str) -> str | None:
return f"Approval {outcome}."
def _on_domain_event(self, event: DomainEvent) -> None:
+ if self.thread_client.runtime_mode == "service" and event.type.startswith(
+ "thread."
+ ):
+ thread = event.payload.get("thread")
+ if (
+ isinstance(thread, dict)
+ and thread.get("id") == self.thread_client.session_id
+ ):
+ self._external_refresh_dirty = True
+ if (
+ self._external_refresh_task is None
+ or self._external_refresh_task.done()
+ ):
+
+ async def refresh():
+ try:
+ while self._external_refresh_dirty:
+ self._external_refresh_dirty = False
+ await self._reload_current_session()
+ except (ApplicationError, OSError, ValueError) as exc:
+ self.console.print(
+ f"Service state refresh failed: {escape(str(exc))}"
+ )
+
+ self._external_refresh_task = asyncio.create_task(refresh())
if event.type != "approval.requested":
return
approval = event.payload.get("approval")
@@ -858,6 +895,7 @@ def _banner(self) -> None:
)
self.console.print(
f" [{theme.META_STYLE}]session {escape(self.bridge.session_id)} · "
+ f"runtime {self.thread_client.runtime_mode} · "
f"access {escape(self.thread_client.access_summary())} · "
f"effort {escape(self.requested_reasoning_effort)}[/]",
soft_wrap=True,
@@ -876,6 +914,7 @@ async def repl(self) -> int:
if self.reader.interactive:
self._banner()
try:
+ self.render_resume_tail()
while not self._exit_requested:
try:
line = await self.reader.read()
@@ -929,6 +968,11 @@ async def repl(self) -> int:
self.console.print(f"[{theme.META_STYLE}]bye[/]")
return 0
finally:
+ if self._external_refresh_task is not None:
+ self._external_refresh_task.cancel()
+ await asyncio.gather(
+ self._external_refresh_task, return_exceptions=True
+ )
self.goal_controller.close()
await self.thread_client.close()
if self._session_activity is not None:
@@ -937,7 +981,7 @@ async def repl(self) -> int:
self._leased_session_id = None
-def main(argv: list[str] | None = None) -> int:
+def main(argv: list[str] | None = None, *, shared_service: bool = True) -> int:
parser = argparse.ArgumentParser(
prog="deepcode",
description="Interactive DeepCode coding agent (multi-turn TUI).",
@@ -949,16 +993,17 @@ def main(argv: list[str] | None = None) -> int:
add_access_preset_argument(parser)
add_workspace_trust_argument(parser)
parser.add_argument("--resume", "-r", default=None, help="Session id to resume.")
- parser.add_argument(
- "--max-iterations",
- type=int,
- default=None,
- help="Optional model-sampling limit for diagnostics (unlimited by default).",
- )
+ parser.set_defaults(max_iterations=None)
+ if not shared_service:
+ parser.add_argument(
+ "--max-iterations", type=int, help="Optional model-sampling limit."
+ )
args = parser.parse_args(argv)
_bootstrap_quiet_logging()
- if not _prepare_workspace_trust(args.workspace, grant=args.trust):
+ if not shared_service and not _prepare_workspace_trust(
+ args.workspace, grant=args.trust
+ ):
return 1
try:
@@ -971,6 +1016,7 @@ def main(argv: list[str] | None = None) -> int:
trust_workspace=args.trust,
access_preset=parse_access_preset(args.access),
resume_id=args.resume,
+ shared_service=shared_service,
)
except ConfigError as exc:
print(format_config_error(exc), file=sys.stderr)
@@ -1034,6 +1080,8 @@ def _silence_console_logging() -> None:
def _prepare_workspace_trust(workspace: str, *, grant: bool) -> bool:
"""Persist trust before creating a Session, avoiding empty denied threads."""
+ from core.application.application import DeepCodeApplication
+
application = DeepCodeApplication.open(
host_surface="cli-trust",
run_automation_scheduler=False,
diff --git a/cli/tui/commands.py b/cli/tui/commands.py
index 6a7d1bbd5..82e8c3356 100644
--- a/cli/tui/commands.py
+++ b/cli/tui/commands.py
@@ -16,8 +16,8 @@
from datetime import UTC, datetime
from typing import Any
-from cli.transcript import TranscriptMode
from cli.execution_options import parse_context_window
+from cli.transcript import TranscriptMode
from cli.tui import theme
from cli.tui.picker import Picker, PickerItem, PickerScope, PickerVariant
from cli.tui.text import fit_head, short_path
@@ -41,6 +41,10 @@ class Command:
_HELP_USAGE_COLUMN_CAP = 30
+async def _cmd_reconnect(app, args: str) -> str:
+ return await app.reconnect_service()
+
+
async def _cmd_help(app, args: str) -> str | None:
"""Aligned command table: the usage column fits the widest short usage,
and an over-long usage moves its description to a wrapped second line
@@ -681,9 +685,7 @@ async def _cmd_skills(app, args: str) -> str | None:
async def _skill_via_picker(app) -> str | None:
try:
- skills = app.thread_client.application.skills.list(
- app.thread_client.project.id
- ).skills
+ skills = app.thread_client.skills.list(app.thread_client.project.id).skills
except (OSError, RuntimeError, ValueError) as exc:
return f"Skill listing failed: {exc}"
selected = set(app.selected_skill_ids)
@@ -760,6 +762,9 @@ async def _cmd_exit(app, args: str) -> str | None:
c.name: c
for c in (
Command("help", "/help", "show this help", _cmd_help),
+ Command(
+ "reconnect", "/reconnect", "Reconnect to the shared service", _cmd_reconnect
+ ),
Command("new", "/new [title]", "start a new conversation", _cmd_new),
Command(
"resume",
diff --git a/cli/tui/goal_controller.py b/cli/tui/goal_controller.py
index bfc38a23c..6d6e7005a 100644
--- a/cli/tui/goal_controller.py
+++ b/cli/tui/goal_controller.py
@@ -7,7 +7,6 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
-from core.application.application import DeepCodeApplication
from core.application.errors import ApplicationError
from core.config import ConfigError
from core.domain.message_provenance import ClientSurface
@@ -211,15 +210,15 @@ def request_stop() -> None:
refresh_session=True,
)
- def _context(self) -> tuple[DeepCodeApplication, str]:
+ def _context(self):
return (
- self.owner.thread_client.application,
+ self.owner.thread_client,
self.owner.thread_client.session_id,
)
@staticmethod
def _required(
- application: DeepCodeApplication,
+ application,
thread_id: str,
) -> ThreadGoal:
goal = application.goals.read(thread_id)
diff --git a/cli/tui/thread_client.py b/cli/tui/thread_client.py
index bc76c2c06..62c1326b1 100644
--- a/cli/tui/thread_client.py
+++ b/cli/tui/thread_client.py
@@ -4,9 +4,8 @@
import asyncio
from collections.abc import Callable
-from dataclasses import dataclass
-from datetime import datetime
+from cli.thread_client import ThreadListing, TurnDelivery, turn_access_summary
from cli.project_trust import (
open_workspace_project,
require_project_trusted,
@@ -27,7 +26,6 @@
from core.domain.execution_profile import ExecutionProfile, ExecutionSelection
from core.domain.execution_security import (
ExecutionAccessPreset,
- ExecutionSecurityProfile,
)
from core.domain.message_provenance import ClientSurface
from core.domain.project import TrustState
@@ -38,24 +36,6 @@
from core.sessions import SessionStore, get_default_store
-@dataclass(frozen=True, slots=True)
-class TuiDelivery:
- kind: str
- turn: Turn
-
-
-@dataclass(frozen=True, slots=True)
-class ThreadListing:
- """One row of the resume picker — display data only."""
-
- session_id: str
- title: str
- message_count: int
- updated_at: datetime
- workspace: str
- is_current: bool
-
-
class TuiThreadClient:
"""Own one application and expose only interactive CLI operations."""
@@ -123,6 +103,28 @@ def __init__(
self.application.close()
raise
+ runtime_mode = "compatibility"
+
+ @property
+ def llm(self):
+ return self.application.llm
+
+ @property
+ def skills(self):
+ return self.application.skills
+
+ @property
+ def plugins(self):
+ return self.application.plugins
+
+ @property
+ def mcp(self):
+ return self.application.mcp
+
+ @property
+ def goals(self):
+ return self.application.goals
+
@property
def session_id(self) -> str:
return self.thread.id
@@ -162,8 +164,8 @@ def frozen_access_summaries(self) -> tuple[str | None, tuple[str, ...]]:
)
queued = tuple(turn for turn in turns if turn.status is TurnStatus.QUEUED)
return (
- _turn_access_summary(current) if current is not None else None,
- tuple(_turn_access_summary(turn) for turn in queued),
+ turn_access_summary(current) if current is not None else None,
+ tuple(turn_access_summary(turn) for turn in queued),
)
def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None:
@@ -211,7 +213,7 @@ def send(
prompt: str,
*,
skill_ids: tuple[str, ...] = (),
- ) -> TuiDelivery:
+ ) -> TurnDelivery:
active = self.application.turns.executing_for_thread(self.thread.id)
result: InteractiveTurnResult = self.router.send(
self.thread.id,
@@ -225,14 +227,14 @@ def send(
InteractiveDelivery.QUEUED,
}:
self._title_from_first_prompt(prompt)
- return TuiDelivery(result.delivery.value, result.turn)
+ return TurnDelivery(result.delivery.value, result.turn)
def queue(
self,
prompt: str,
*,
skill_ids: tuple[str, ...] = (),
- ) -> TuiDelivery:
+ ) -> TurnDelivery:
snapshot = self.application.turns.enqueue(
self.thread.id,
prompt=prompt,
@@ -241,7 +243,7 @@ def queue(
client_surface=ClientSurface.CLI,
)
self._title_from_first_prompt(prompt)
- return TuiDelivery("queued", snapshot.turn)
+ return TurnDelivery("queued", snapshot.turn)
def has_active_turn(self) -> bool:
return self.application.turns.active_for_thread(self.thread.id) is not None
@@ -568,16 +570,4 @@ def _require_idle(self) -> None:
)
-def _turn_access_summary(turn: Turn) -> str:
- profile: ExecutionSecurityProfile | None = turn.execution_security_profile
- if profile is not None:
- if profile.access_preset is not None:
- return profile.access_preset.value.replace("_", " ")
- sandbox = "sandboxed" if profile.command_sandbox else "unsandboxed"
- return f"legacy {profile.permission_mode.value.replace('_', ' ')} · {sandbox}"
- if turn.execution_permission_mode is not None:
- return f"legacy {turn.execution_permission_mode.value.replace('_', ' ')}"
- return "legacy unknown"
-
-
-__all__ = ["TuiDelivery", "TuiThreadClient"]
+__all__ = ["TuiThreadClient"]
diff --git a/cli/web_cli.py b/cli/web_cli.py
new file mode 100644
index 000000000..1e9be16ff
--- /dev/null
+++ b/cli/web_cli.py
@@ -0,0 +1,50 @@
+"""Open the packaged browser client using an instance-local one-time link."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import webbrowser
+from pathlib import Path
+
+from app_server.service_client import ServiceClient
+from app_server.service_state import ServiceFiles
+from app_server.web_surface import read_web_build
+from cli.service_cli import start_service
+from core.persistence.database import default_database_path
+
+
+def run(argv=None) -> int:
+ parser = argparse.ArgumentParser(prog="deepcode web")
+ parser.add_argument("--database", type=Path, default=default_database_path())
+ parser.add_argument("--port", type=int, default=None)
+ parser.add_argument(
+ "--no-open",
+ action="store_true",
+ help="Print the short-lived link without opening a browser",
+ )
+ parser.add_argument("--json", action="store_true")
+ args = parser.parse_args(argv)
+ files = ServiceFiles(args.database)
+ if not files.running() and read_web_build() is None:
+ parser.error(
+ "Web assets are missing or incompatible. Install a complete release, "
+ "or run npm run build:web in desktop/."
+ )
+ status = start_service(files, port=args.port)
+ ticket = ServiceClient(files).call("auth/issue", instance_id=status["instanceId"])
+ url = status["url"] + "/#ticket=" + ticket["ticket"]
+ print(
+ json.dumps(
+ {
+ "url": url,
+ "expiresIn": ticket["expiresIn"],
+ "instanceId": status["instanceId"],
+ }
+ )
+ if args.json
+ else f"Open within {ticket['expiresIn']} seconds (one use):\n{url}"
+ )
+ if not args.no_open:
+ webbrowser.open(url)
+ return 0
diff --git a/core/agent_runtime/injections.py b/core/agent_runtime/injections.py
index 025f9e0f8..12cca1204 100644
--- a/core/agent_runtime/injections.py
+++ b/core/agent_runtime/injections.py
@@ -255,6 +255,9 @@ def deactivate(self, turn_id: str) -> None:
return
self._active_turn_id = None
self._state = MailboxState.CLOSED
+ for entry in self._pending:
+ if not entry.ready:
+ self._seen.pop(entry.message_id, None)
self._pending.clear()
self._pending_by_id.clear()
self._pending_chars = 0
@@ -273,13 +276,17 @@ def reserve(self, value: TurnRuntimeInput) -> TurnInputReservation | None:
# message is appended. Wait for that ordering boundary rather
# than rejecting a valid early Steer or persisting it first.
self._condition.wait()
- previous = self._seen.get(clean_value.message_id)
- if previous is not None:
+ while (previous := self._seen.get(clean_value.message_id)) is not None:
if previous != digest:
raise TurnInputConflictError(
"message_id was already used with different content"
)
- return None
+ pending = self._pending_by_id.get(clean_value.message_id)
+ if pending is None or pending.ready:
+ return None
+ # A reservation is not an acceptance. Wait for commit/cancel,
+ # releasing the condition so the original producer can finish.
+ self._condition.wait()
if self._state is not MailboxState.OPEN:
raise TurnInputClosedError(self._state)
if self._active_turn_id != clean_value.target_turn_id:
@@ -319,9 +326,10 @@ def commit(self, reservation: TurnInputReservation) -> None:
def cancel(self, reservation: TurnInputReservation) -> None:
with self._condition:
- entry = self._pending_by_id.pop(reservation.message_id, None)
- if entry is None:
+ entry = self._pending_by_id.get(reservation.message_id)
+ if entry is None or entry.ready:
return
+ self._pending_by_id.pop(reservation.message_id)
self._pending = deque(
candidate
for candidate in self._pending
diff --git a/core/agent_setup.py b/core/agent_setup.py
index 43beb1e28..ddfec4960 100644
--- a/core/agent_setup.py
+++ b/core/agent_setup.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import inspect
+from collections.abc import Awaitable, Callable
import os
from typing import Any
@@ -258,6 +259,7 @@ def build_agent_session(
permission_mode_override: PermissionMode | None = None,
execution_security_profile: ExecutionSecurityProfile | None = None,
runtime: DeepCodeRuntime | None = None,
+ provider_cleanup: Callable[[], Awaitable[None]] | None = None,
skill_runtime: Any | None = None,
project_trusted: bool = False,
plugin_mcp_servers: tuple[Any, ...] = (),
@@ -499,6 +501,7 @@ def _permission_checker(name: str, arguments: dict[str, Any]):
goal_runtime.closure_prompt if goal_runtime is not None else None
),
mcp_runtime=mcp_runtime,
+ provider_cleanup=provider_cleanup,
)
if control is not None:
# `session.history` is a @property (a list), so it must be wrapped in a
diff --git a/core/application/agent_adapter.py b/core/application/agent_adapter.py
index e6705f9b0..6df90af78 100644
--- a/core/application/agent_adapter.py
+++ b/core/application/agent_adapter.py
@@ -136,7 +136,10 @@ def create(
options["tool_filter"] = preset_filter
if agent_preset.allow_spawn is not None:
options["allow_spawn"] = agent_preset.allow_spawn
- runtime = DeepCodeRuntime(load_config_for_workspace(workspace))
+ runtime = DeepCodeRuntime(
+ load_config_for_workspace(workspace),
+ config_loader=lambda: load_config_for_workspace(workspace),
+ )
plugin_mcp_servers = (
self._plugin_mcp_provider(Path(workspace))
if self._plugin_mcp_provider is not None
@@ -163,6 +166,7 @@ def create(
else None
),
runtime=runtime,
+ provider_cleanup=runtime.aclose,
project_trusted=True,
plugin_mcp_servers=plugin_mcp_servers,
mcp_status_observer=self._mcp_status_observer,
@@ -189,6 +193,12 @@ def runtime_key(
(
execution_profile.connection_id,
execution_profile.config_revision,
+ execution_profile.provider_revision,
+ execution_profile.protocol,
+ execution_profile.model_id,
+ execution_profile.input_modalities,
+ execution_profile.tool_calling,
+ execution_profile.reasoning_supported,
execution_profile.context_window,
execution_profile.max_output_tokens,
execution_profile.max_tokens,
diff --git a/core/application/application.py b/core/application/application.py
index 998ef86e7..84aab8536 100644
--- a/core/application/application.py
+++ b/core/application/application.py
@@ -302,6 +302,9 @@ def open(
event_relay_batch_size=event_relay_batch_size,
)
application._application_lease = lease
+ if database.restore_recovery_marker.exists():
+ application._pause_restored_scheduling()
+ database.restore_recovery_marker.unlink()
application.event_relay.start()
application.execution_coordinator.start(background=False)
if lease.recovery_owner:
@@ -329,6 +332,35 @@ def open(
lease.close()
raise
+ def _pause_restored_scheduling(self) -> None:
+ """A data rollback cannot roll back tool effects in project directories."""
+ from core.domain.automation import (
+ AutomationActivationStatus,
+ AutomationScheduleKind,
+ AutomationStatus,
+ )
+ from core.domain.thread_goal import ThreadGoalStatus
+
+ for goal in self.goals.store.list_current():
+ if goal.status is ThreadGoalStatus.ACTIVE:
+ self.goals.pause(goal.thread_id, expected_goal_id=goal.id)
+ definitions = []
+ offset = 0
+ while True:
+ page = self.automations.list(limit=100, offset=offset)
+ definitions.extend(page.automations)
+ if not page.has_more:
+ break
+ offset = page.next_offset
+ for definition in definitions:
+ if (
+ definition.status is AutomationStatus.ENABLED
+ and definition.schedule_kind is AutomationScheduleKind.INTERVAL
+ ):
+ self.automations.update(
+ definition.id, status=AutomationActivationStatus.PAUSED
+ )
+
def close(self) -> None:
errors: list[Exception] = []
@@ -347,6 +379,7 @@ def attempt(stage: str, operation) -> None:
"execution runtime",
lambda: self.executions.close(cleanup=self.turns.close_live_sessions),
)
+ attempt("Provider login", self.llm.close)
attempt("Plugin service", self.plugins.close)
attempt("Skill service", self.skills.close)
attempt("Skill workspace registry", self.skill_hosts.close)
diff --git a/core/application/config_store.py b/core/application/config_store.py
index fca25428e..7b52acd43 100644
--- a/core/application/config_store.py
+++ b/core/application/config_store.py
@@ -6,14 +6,17 @@
import json
import os
import threading
-import uuid
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from core.config import DeepCodeConfig, home_config_path
-from core.private_storage import ensure_private_directory, open_private_file
+from core.private_storage import (
+ atomic_write_private_json,
+ ensure_private_directory,
+ open_private_file,
+)
class ConfigRevisionConflict(RuntimeError):
@@ -85,29 +88,7 @@ def mutate(
return updated
def _replace(self, value: dict[str, Any]) -> None:
- ensure_private_directory(self.path.parent)
- temporary = self.path.with_name(f".{self.path.name}.{uuid.uuid4().hex}.tmp")
- payload = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
- descriptor = open_private_file(
- temporary,
- os.O_WRONLY | os.O_CREAT | os.O_EXCL,
- )
- try:
- with os.fdopen(descriptor, "wb") as handle:
- handle.write(payload)
- handle.flush()
- os.fsync(handle.fileno())
- os.replace(temporary, self.path)
- try:
- os.chmod(self.path, 0o600)
- except OSError:
- pass
- _fsync_directory(self.path.parent)
- finally:
- try:
- temporary.unlink()
- except FileNotFoundError:
- pass
+ atomic_write_private_json(self.path, value)
def deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
@@ -151,13 +132,3 @@ def _file_lock(path: Path) -> Iterator[None]:
fcntl.flock(descriptor, fcntl.LOCK_UN)
finally:
os.close(descriptor)
-
-
-def _fsync_directory(path: Path) -> None:
- if os.name == "nt":
- return
- descriptor = os.open(path, os.O_RDONLY)
- try:
- os.fsync(descriptor)
- finally:
- os.close(descriptor)
diff --git a/core/application/errors.py b/core/application/errors.py
index a25aeed45..74b11afed 100644
--- a/core/application/errors.py
+++ b/core/application/errors.py
@@ -221,6 +221,15 @@ class DuplicateMessageConflictError(ApplicationError):
code = "DUPLICATE_MESSAGE_CONFLICT"
+class InputDeliveryPendingError(ApplicationError):
+ code = "INPUT_DELIVERY_PENDING"
+ retryable = True
+
+
+class InputDeliveryUncertainError(ApplicationError):
+ code = "INPUT_DELIVERY_UNCERTAIN"
+
+
class TurnInterruptTimeoutError(ApplicationError):
code = "TURN_INTERRUPT_TIMEOUT"
retryable = True
diff --git a/core/application/event_service.py b/core/application/event_service.py
index 2a5915e8f..f338827c7 100644
--- a/core/application/event_service.py
+++ b/core/application/event_service.py
@@ -35,6 +35,7 @@ class ReplayPage:
events: tuple[DomainEvent, ...]
next_after: int | None
has_more: bool
+ head_sequence: int | None = None
@dataclass(slots=True)
@@ -174,15 +175,28 @@ def replay(
return list(self.replay_page(thread_id, after=after, limit=limit).events)
def replay_page(
- self, thread_id: str, *, after: int = 0, limit: int = 500
+ self,
+ thread_id: str,
+ *,
+ after: int = 0,
+ limit: int = 500,
+ through: int | None = None,
) -> ReplayPage:
if after < 0:
raise ValueError("after must not be negative")
if not 1 <= limit <= 1000:
raise ValueError("limit must be between 1 and 1000")
+ if through is not None and through < 0:
+ raise ValueError("through must not be negative")
with self.database.read() as connection:
- events = EventRepository(connection).replay(
- thread_id, after=after, limit=limit + 1
+ repository = EventRepository(connection)
+ # Capture the cutoff before reading the page. Later commits cannot
+ # extend this replay round; clients carry the cutoff to later pages.
+ head = repository.sequence_head(thread_id)
+ if through is not None:
+ head = min(head, through)
+ events = repository.replay(
+ thread_id, after=after, limit=limit + 1, through=head
)
has_more = len(events) > limit
page = tuple(events[:limit])
@@ -190,6 +204,7 @@ def replay_page(
events=page,
next_after=page[-1].sequence if has_more and page else None,
has_more=has_more,
+ head_sequence=head,
)
diff --git a/core/application/execution_coordinator.py b/core/application/execution_coordinator.py
index d345bcbe3..af1497fcb 100644
--- a/core/application/execution_coordinator.py
+++ b/core/application/execution_coordinator.py
@@ -147,6 +147,8 @@ def __init__(
)
self._lock = threading.RLock()
+ self._admission_lock = threading.RLock()
+ self._admission_paused = False
self._stop = threading.Event()
self._wake = threading.Event()
self._thread: threading.Thread | None = None
@@ -287,7 +289,28 @@ def dispatch_once(
) -> tuple[ExecutionDispatch, ...]:
"""Claim eligible work, then send admitted Turns to the injected starter."""
- self._require_dispatchable()
+ with self._admission_lock:
+ self._require_dispatchable()
+ if self._admission_paused:
+ return ()
+ return self._dispatch_once(candidate_limit=candidate_limit)
+
+ def pause_admission(self) -> None:
+ """Fence new starts while keeping heartbeat and cancellation processing."""
+
+ with self._admission_lock:
+ self._require_dispatchable()
+ self._admission_paused = True
+
+ def resume_admission(self) -> None:
+ """Resume after a cancelled or timed-out drain."""
+
+ with self._admission_lock:
+ self._require_dispatchable()
+ self._admission_paused = False
+ self.offer()
+
+ def _dispatch_once(self, *, candidate_limit: int) -> tuple[ExecutionDispatch, ...]:
now = self._now()
claimed: list[ExecutionDispatch] = []
with self.database.transaction() as connection:
diff --git a/core/application/goal_extension.py b/core/application/goal_extension.py
index ddd627afd..05ad39c21 100644
--- a/core/application/goal_extension.py
+++ b/core/application/goal_extension.py
@@ -130,6 +130,22 @@ def is_turn_accounted(
turn_id=turn_id,
)
+ def execution_settled(self, goal: ThreadGoal) -> bool:
+ """A terminal Goal is ready only after its deciding Turn is accounted."""
+ if goal.status is ThreadGoalStatus.ACTIVE:
+ return False
+ outcome = self.read_outcome(goal.thread_id)
+ deciding_turn_id = outcome.decided_by_turn_id if outcome else None
+ if deciding_turn_id is None:
+ return True
+ try:
+ turn = self.turns.read(deciding_turn_id).turn
+ except TurnNotFoundError:
+ return True
+ return turn.status.is_terminal and self.is_turn_accounted(
+ goal.thread_id, goal_id=goal.id, turn_id=turn.id
+ )
+
def are_turns_accounted(
self,
thread_id: str,
diff --git a/core/application/input_identity.py b/core/application/input_identity.py
new file mode 100644
index 000000000..edeba371c
--- /dev/null
+++ b/core/application/input_identity.py
@@ -0,0 +1,51 @@
+"""Stable submission identity, independent of changing runtime defaults."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+
+from core.domain.execution_permission import ExecutionPermissionMode
+from core.domain.execution_security import ExecutionSecurityProfile
+from core.domain.message_provenance import TurnInputDelivery, TurnInputSource
+from core.domain.runtime_coordination import ExecutionClass
+from core.skills.models import SkillSelection
+
+
+def submission_fingerprint(
+ *,
+ prompt: str,
+ skill_ids: tuple[str, ...],
+ connection_id: str | None,
+ model: str | None,
+ reasoning_effort: str | None,
+ source: TurnInputSource,
+ delivery: TurnInputDelivery,
+ execution_class: ExecutionClass,
+ security_override: ExecutionSecurityProfile | None,
+ permission_override: ExecutionPermissionMode | None,
+) -> str:
+ # Requested fields, not the resolved profile or Goal-inherited skills:
+ # a lost response must stay recoverable after those defaults change.
+ payload = {
+ "prompt": prompt.strip(),
+ "skills": [SkillSelection(skill_id=s).skill_id for s in skill_ids],
+ "connectionId": connection_id,
+ "model": model,
+ "reasoningEffort": reasoning_effort,
+ "source": source.value,
+ "delivery": delivery.value,
+ "executionClass": execution_class.value,
+ "securityOverride": security_override.to_dict() if security_override else None,
+ "permissionOverride": permission_override.value
+ if permission_override
+ else None,
+ }
+ encoded = json.dumps(
+ payload,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ allow_nan=False,
+ ).encode()
+ return "v1:" + hashlib.sha256(encoded).hexdigest()
diff --git a/core/application/llm_configuration_service.py b/core/application/llm_configuration_service.py
index f28c7ac27..5c2f96ed2 100644
--- a/core/application/llm_configuration_service.py
+++ b/core/application/llm_configuration_service.py
@@ -14,6 +14,11 @@
)
from core.application.errors import ConflictError, InvalidArgumentError
from core.application.project_service import ProjectService
+from core.application.provider_verification import (
+ verification_stage as _verification_stage,
+ model_error_detail as _model_error_detail,
+ verify_agent,
+)
from core.config import (
ConnectionProfileConfig,
DeepCodeConfig,
@@ -21,9 +26,10 @@
load_config_for_workspace,
)
from core.domain.execution_profile import ExecutionProfile, ExecutionSelection
-from core.providers.catalog_service import ModelCatalogService
+from core.providers.catalog_service import ModelCatalogService, ModelCatalog
from core.providers.credentials import CredentialStore
from core.providers.profiles import ConnectionResolver, validate_connection_id
+from core.providers.oauth import ProviderOAuthManager
from core.providers.reasoning import infer_reasoning_capabilities
from core.providers.registry import PROVIDERS, find_by_name
@@ -32,6 +38,9 @@
"label",
"template",
"adapter",
+ "protocol",
+ "auth",
+ "compat",
"apiBase",
"apiKeyEnv",
"apiKey",
@@ -63,6 +72,7 @@ def __init__(
self.config_store = config_store or ConfigStore()
self.credentials = credential_store or CredentialStore()
self.catalog = catalog or ModelCatalogService()
+ self.oauth = ProviderOAuthManager(self.credentials)
def list_connections(self, project_id: str | None = None) -> dict[str, Any]:
config = self._config(project_id=project_id)
@@ -134,6 +144,10 @@ def transform(current: dict[str, Any]) -> dict[str, Any]:
existing=existing,
providers=providers,
)
+ if normalized["auth"] == "oauth" and api_key is not None:
+ raise InvalidArgumentError(
+ "Use provider login for OAuth connections, or select API-key authentication"
+ )
profiles[connection_id] = normalized
providers["profiles"] = profiles
return {**current, "providers": providers}
@@ -155,6 +169,9 @@ def transform(current: dict[str, Any]) -> dict[str, Any]:
)
if not credential_only_builtin:
self._mutate_config(transform, expected_revision)
+ self.credentials.begin_login(
+ connection_id
+ ) # invalidate pending flows after configuration changes
if clear_api_key:
self.credentials.clear(connection_id)
if api_key is not None:
@@ -209,6 +226,36 @@ def transform(current: dict[str, Any]) -> dict[str, Any]:
**self.list_connections(),
}
+ def close(self) -> None:
+ self.oauth.close()
+
+ def login_start(self, connection_id: str, *, open_browser: bool = False) -> dict:
+ connection = self._resolver().resolve_connection(connection_id)
+ if connection.auth != "oauth" or not connection.enabled:
+ raise InvalidArgumentError(
+ "Save an enabled OpenRouter OAuth connection before signing in"
+ )
+ return self.oauth.start(connection.id, open_browser=open_browser)
+
+ def login_poll(self, flow_id: str) -> dict:
+ return self.oauth.poll(flow_id)
+
+ def login_cancel(self, flow_id: str) -> dict:
+ return self.oauth.cancel(flow_id)
+
+ def logout(self, connection_id: str) -> dict:
+ connection = self._resolver().resolve_connection(connection_id)
+ if connection.auth != "oauth":
+ raise InvalidArgumentError(
+ "The selected connection does not use Provider login"
+ )
+ self.credentials.clear(connection.id)
+ return {
+ "disconnected": True,
+ "remoteRevoked": False,
+ "manageUrl": "https://openrouter.ai/settings/keys",
+ }
+
def discover_models(
self,
*,
@@ -217,6 +264,7 @@ def discover_models(
api_base: str | None = None,
api_key: str | None = None,
project_id: str | None = None,
+ draft: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Probe an endpoint AS SHOWN in an editor form; writes nothing.
@@ -226,9 +274,15 @@ def discover_models(
leaves memory — discovery returns candidates, adopting writes
(the dsh rule).
"""
- resolver = self._resolver(project_id=project_id)
+ resolver = (
+ self._draft_resolver(draft, project_id)
+ if draft is not None
+ else self._resolver(project_id=project_id)
+ )
try:
- if connection_id and connection_id.strip():
+ if draft is not None:
+ base = resolver.resolve_connection(str(draft["id"]))
+ elif connection_id and connection_id.strip():
base = resolver.resolve_connection(connection_id.strip())
elif template and template.strip():
base = resolver.template_connection(template.strip())
@@ -296,6 +350,8 @@ def test(
*,
project_id: str | None = None,
model_id: str | None = None,
+ draft: dict[str, Any] | None = None,
+ mode: str = "quick",
) -> dict[str, Any]:
"""Check one connection and optionally run a minimal real model request.
@@ -305,8 +361,20 @@ def test(
credential may call a particular model.
"""
+ if mode not in {"quick", "agent"}:
+ raise InvalidArgumentError("Unknown verification mode")
+ if mode == "agent" and not _clean_optional(model_id):
+ raise InvalidArgumentError("Agent verification requires a model")
+ if draft is not None and draft.get("id") != connection_id:
+ raise InvalidArgumentError(
+ "Draft connection ID does not match the selected connection"
+ )
try:
- resolver = self._resolver(project_id=project_id)
+ resolver = (
+ self._draft_resolver(draft, project_id)
+ if draft is not None
+ else self._resolver(project_id=project_id)
+ )
connection = resolver.resolve_connection(connection_id)
except ValueError as exc:
raise InvalidArgumentError(str(exc)) from exc
@@ -338,7 +406,32 @@ def test(
_credential_detail(connection.credential_source),
)
catalog_started = time.monotonic()
- catalog = self.catalog.list_models(connection, refresh=True)
+ if draft is None:
+ catalog = self.catalog.list_models(connection, refresh=True)
+ else:
+ # Form probes never persist catalogs, credentials or route revisions.
+ try:
+ models = (
+ self.catalog.probe(connection)
+ if connection.model_catalog != "manual"
+ else ()
+ )
+ catalog = ModelCatalog(
+ connection_id=connection.id,
+ models=models,
+ source="remote"
+ if connection.model_catalog != "manual"
+ else "manual",
+ stale=False,
+ )
+ except Exception as exc:
+ catalog = ModelCatalog(
+ connection_id=connection.id,
+ models=(),
+ source="fallback",
+ stale=True,
+ error=_safe_configuration_error(exc),
+ )
catalog_latency = round((time.monotonic() - catalog_started) * 1000)
if catalog.source == "manual" and not catalog.stale:
catalog_stage = _verification_stage(
@@ -372,7 +465,12 @@ def test(
"Choose a model to run a minimal verification request",
model_id=clean_model,
)
- if clean_model is not None:
+ agent_stages = []
+ if clean_model is not None and mode == "agent":
+ agent_stages = self._verify_agent(
+ resolver, connection.id, clean_model, catalog
+ )
+ elif clean_model is not None:
model_stage = self._verify_model(
resolver,
connection_id=connection.id,
@@ -380,7 +478,18 @@ def test(
catalog=catalog,
)
- if clean_model is not None:
+ if mode == "agent":
+ required = {"stream", "tool", "continuation"}
+ ok = all(
+ any(
+ stage["id"] == name and stage["status"] == "passed"
+ for stage in agent_stages
+ )
+ for name in required
+ ) and not any(stage["status"] == "failed" for stage in agent_stages)
+ status = "ready" if ok else "error"
+ error = None if ok else "Agent compatibility verification did not pass"
+ elif clean_model is not None:
ok = model_stage["status"] == "passed"
status = "ready" if ok else "error"
error = None if ok else str(model_stage["detail"])
@@ -404,7 +513,9 @@ def test(
started=started,
model_count=len(catalog.models),
error=error,
- stages=(credential, catalog_stage, model_stage),
+ stages=(credential, catalog_stage, *agent_stages)
+ if mode == "agent"
+ else (credential, catalog_stage, model_stage),
)
def _verify_model(
@@ -426,6 +537,7 @@ def _verify_model(
model_id=model_id,
),
phase="implementation",
+ persist_revision=False,
model_limits=(
(
catalog_model.context_window,
@@ -447,17 +559,22 @@ def _verify_model(
model_id=model_id,
)
- started = time.monotonic()
- try:
- response = _run_probe_isolated(
- provider.chat(
+ async def probe():
+ try:
+ return await provider.chat(
messages=[{"role": "user", "content": _MODEL_PROBE_PROMPT}],
model=profile.model_id,
max_tokens=min(16, profile.max_output_tokens),
temperature=0,
reasoning_effort=None,
- ),
- timeout=_MODEL_PROBE_TIMEOUT_SECONDS,
+ )
+ finally:
+ await provider.aclose()
+
+ started = time.monotonic()
+ try:
+ response = _run_probe_isolated(
+ probe(), timeout=_MODEL_PROBE_TIMEOUT_SECONDS
)
except TimeoutError:
return _verification_stage(
@@ -543,6 +660,47 @@ def resolve_phases(
except ValueError as exc:
raise InvalidArgumentError(str(exc)) from exc
+ def _draft_resolver(
+ self, draft: dict[str, Any], project_id: str | None
+ ) -> ConnectionResolver:
+ connection_id, key, clear = self._parse_mutation(draft)
+ config = self._config(project_id=project_id).model_copy(deep=True)
+ providers = config.providers.model_dump(by_alias=True, exclude_none=True)
+ existing = providers.get("profiles", {}).get(connection_id)
+ normalized = self._normalize_profile(
+ draft, connection_id=connection_id, existing=existing, providers=providers
+ )
+ config.providers.profiles[connection_id] = (
+ ConnectionProfileConfig.model_validate(normalized)
+ )
+ overrides = {connection_id: key} if key is not None or clear else {}
+ return ConnectionResolver(
+ config, self.credentials, credential_overrides=overrides
+ )
+
+ def _verify_agent(self, resolver, connection_id, model_id, catalog):
+ selected = next((item for item in catalog.models if item.id == model_id), None)
+ try:
+ profile = resolver.execution_profile(
+ ExecutionSelection(connection_id, model_id),
+ model_limits=(selected.context_window, selected.max_output_tokens)
+ if selected
+ else None,
+ reasoning_capabilities=selected.reasoning if selected else None,
+ persist_revision=False,
+ )
+ provider = resolver.build_provider(profile)
+ return _run_probe_isolated(verify_agent(provider, profile), timeout=95)
+ except Exception as exc:
+ return [
+ _verification_stage(
+ "stream",
+ "failed",
+ _safe_configuration_error(exc),
+ model_id=model_id,
+ )
+ ]
+
def _resolver(self, project_id: str | None = None) -> ConnectionResolver:
return ConnectionResolver(self._config(project_id=project_id), self.credentials)
@@ -604,6 +762,9 @@ def _normalize_profile(
"label",
"template",
"adapter",
+ "protocol",
+ "auth",
+ "compat",
"apiBase",
"apiKeyEnv",
"extraHeaders",
@@ -637,25 +798,6 @@ def _clean_optional(value: Any) -> str | None:
return clean or None
-def _verification_stage(
- stage_id: str,
- status: str,
- detail: str,
- *,
- latency_ms: int | None = None,
- model_count: int | None = None,
- model_id: str | None = None,
-) -> dict[str, Any]:
- return {
- "id": stage_id,
- "status": status,
- "detail": detail[:300],
- "latencyMs": latency_ms,
- "modelCount": model_count,
- "modelId": model_id,
- }
-
-
def _verification_result(
connection_id: str,
*,
@@ -684,7 +826,9 @@ def _credential_detail(source: str) -> str:
"environment": "Credential resolved from the configured environment variable",
"credential_store": "Credential loaded from DeepCode private storage",
"legacy_config": "Credential loaded from legacy DeepCode configuration",
- "not_required": "This local or direct connection does not require a credential",
+ "not_required": "This connection does not require a credential",
+ "request": "Unsaved credential supplied for this verification only",
+ "oauth": "Credential bound to the signed-in OpenRouter account",
}.get(source, "Credential is configured")
@@ -712,27 +856,6 @@ def _safe_configuration_error(exc: Exception) -> str:
return f"{type(exc).__name__}: model verification could not be completed"
-def _model_error_detail(response: Any) -> str:
- status = response.error_status_code
- if status == 401:
- return "The provider rejected the API credential"
- if status == 403:
- return "The credential does not have access to this model"
- if status == 404:
- return "The endpoint or selected model was not found"
- if status == 408:
- return "The model verification request timed out"
- if status == 429:
- return "The provider reported a rate, quota, or balance limit"
- if isinstance(status, int) and status >= 500:
- return "The provider is temporarily unavailable"
- if response.error_kind == "timeout":
- return "The model verification request timed out"
- if response.error_kind == "connection":
- return "DeepCode could not connect to the model endpoint"
- return "The provider rejected the model verification request"
-
-
def _profile_seed(
connection_id: str,
*,
@@ -756,6 +879,9 @@ def _profile_seed(
"label": spec.label,
"template": spec.name,
"adapter": spec.backend,
+ "protocol": legacy.get("protocol", "auto"),
+ "auth": legacy.get("auth", "api_key"),
+ "compat": legacy.get("compat", {}),
"apiBase": legacy.get("apiBase", legacy.get("api_base"))
or spec.default_api_base
or None,
diff --git a/core/application/provider_verification.py b/core/application/provider_verification.py
new file mode 100644
index 000000000..5739dadfc
--- /dev/null
+++ b/core/application/provider_verification.py
@@ -0,0 +1,253 @@
+"""Bounded, read-only model compatibility probes using the production adapters."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import secrets
+import time
+from typing import Any
+
+from core.agent_runtime.helpers import build_assistant_message
+from core.domain.execution_profile import ExecutionProfile
+from core.providers.base import LLMProvider
+
+
+def verification_stage(
+ stage_id: str,
+ status: str,
+ detail: str,
+ *,
+ latency_ms: int | None = None,
+ model_count: int | None = None,
+ model_id: str | None = None,
+) -> dict[str, Any]:
+ return {
+ "id": stage_id,
+ "status": status,
+ "detail": detail[:300],
+ "latencyMs": latency_ms,
+ "modelCount": model_count,
+ "modelId": model_id,
+ }
+
+
+def model_error_detail(response: Any) -> str:
+ status = response.error_status_code
+ if status == 401:
+ return "The provider rejected the API credential"
+ if status == 403:
+ return "The credential does not have access to this model"
+ if status == 404:
+ return "The endpoint or selected model was not found"
+ if status == 408:
+ return "The model verification request timed out"
+ if status == 429:
+ return "The provider reported a rate, quota, or balance limit"
+ if isinstance(status, int) and status >= 500:
+ return "The provider is temporarily unavailable"
+ if response.error_kind == "timeout":
+ return "The model verification request timed out"
+ if response.error_kind == "connection":
+ return "DeepCode could not connect to the model endpoint"
+ return "The provider rejected the model verification request"
+
+
+async def verify_agent(provider: LLMProvider, profile: ExecutionProfile) -> list[dict]:
+ """At most three requests, 90 seconds total, no shell/file/network tools.
+
+ The second request consumes a random value revealed only by the local
+ tool result, so merely accepting a tool schema cannot pass the probe.
+ """
+ stages = []
+ stage_ids = ("stream", "tool", "continuation", "reasoning", "image")
+ current = "stream"
+ started = time.monotonic()
+ observed_reasoning = False
+ streamed_text = False
+
+ def record(stage, status, detail):
+ stages.append(
+ verification_stage(
+ stage,
+ status,
+ detail,
+ latency_ms=round((time.monotonic() - started) * 1000),
+ model_id=profile.model_id,
+ )
+ )
+
+ async def content_delta(text):
+ nonlocal streamed_text
+ streamed_text = streamed_text or bool(text)
+
+ async def reasoning_delta(text, _channel):
+ nonlocal observed_reasoning
+ observed_reasoning = observed_reasoning or bool(text)
+
+ async def request(messages, **kwargs):
+ response = await provider.chat_stream(
+ messages=messages,
+ model=profile.model_id,
+ max_tokens=min(1024, profile.max_output_tokens),
+ temperature=0,
+ reasoning_effort=profile.reasoning_effort,
+ on_content_delta=content_delta,
+ on_reasoning_delta=reasoning_delta,
+ **kwargs,
+ )
+ if response.finish_reason == "error":
+ raise ValueError(model_error_detail(response))
+ return response
+
+ try:
+ async with asyncio.timeout(90):
+ if profile.tool_calling is False:
+ record(
+ "tool",
+ "skipped",
+ "Model declared without tool calling; Agent compatibility cannot be established",
+ )
+ record("continuation", "skipped", "Tool calling is disabled")
+ reply = await request([{"role": "user", "content": "Reply with OK"}])
+ record(
+ "stream",
+ "passed" if streamed_text and reply.content else "failed",
+ "Text stream checked",
+ )
+ else:
+ tools = [
+ {
+ "type": "function",
+ "function": {
+ "name": "deepcode_probe",
+ "description": "Return a verification nonce. Call once with value 7.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "value": {"type": "integer", "enum": [7]}
+ },
+ "required": ["value"],
+ "additionalProperties": False,
+ },
+ },
+ }
+ ]
+ messages = [
+ {
+ "role": "user",
+ "content": "Call deepcode_probe once with value 7, then reply with exactly the nonce returned by the tool.",
+ }
+ ]
+ first = await request(
+ messages,
+ tools=tools,
+ tool_choice="auto",
+ )
+ current = "tool"
+ if not first.should_execute_tools or len(first.tool_calls) != 1:
+ raise ValueError("Expected one completed tool call")
+ call = first.tool_calls[0]
+ if (
+ call.name != "deepcode_probe"
+ or call.arguments != {"value": 7}
+ or not call.id
+ ):
+ raise ValueError("The tool call name, ID or arguments were invalid")
+ record(
+ "tool",
+ "passed",
+ "One valid tool call executed by the local verification function",
+ )
+ nonce = secrets.token_hex(12)
+ messages += [
+ build_assistant_message(
+ first.content,
+ tool_calls=[call.to_openai_tool_call()],
+ reasoning_content=first.reasoning_content,
+ reasoning_summary=first.reasoning_summary,
+ provider_state=first.provider_state,
+ thinking_blocks=first.thinking_blocks,
+ ),
+ {
+ "role": "tool",
+ "tool_call_id": call.id,
+ "name": call.name,
+ "content": json.dumps({"nonce": nonce}),
+ },
+ ]
+ current = "continuation"
+ second = await request(messages, tools=tools, tool_choice="auto")
+ if second.tool_calls or (second.content or "").strip() != nonce:
+ raise ValueError(
+ "The model did not consume and reproduce the tool result"
+ )
+ record(
+ "continuation",
+ "passed",
+ "The model consumed the tool result with production reasoning-history serialization",
+ )
+ record(
+ "stream",
+ "passed" if streamed_text else "failed",
+ "Text deltas received"
+ if streamed_text
+ else "No text deltas received",
+ )
+ record(
+ "reasoning",
+ "passed" if observed_reasoning else "skipped",
+ "Provider reasoning deltas observed and history accepted"
+ if observed_reasoning
+ else "No reasoning deltas observed; reasoning remains unverified",
+ )
+ current = "image"
+ if (
+ profile.input_modalities is None
+ or "image" not in profile.input_modalities
+ ):
+ record("image", "skipped", "Image input is not explicitly declared")
+ else:
+ # A valid 64x64 PNG; this checks protocol acceptance, not vision quality.
+ png = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAXklEQVR4nO3PMQ0AMAzAsPInvYLYYVWKESTzjhsd8KsBrQGtAa0BrQGtAa0BrQGtAa0BrQGtAa0BrQGtAa0BrQGtAa0BrQGtAa0BrQGtAa0BrQGtAa0BrQGtAa0BbQHKU9LC7/CP1AAAAABJRU5ErkJggg=="
+ image = await request(
+ [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "An image is attached. Reply OK.",
+ },
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "data:image/png;base64," + png
+ },
+ },
+ ],
+ }
+ ]
+ )
+ record(
+ "image",
+ "passed" if image.content else "failed",
+ "Image request accepted; visual understanding is not evaluated",
+ )
+ except TimeoutError:
+ record(
+ current,
+ "failed",
+ "Compatibility verification exceeded its 90 second budget",
+ )
+ except ValueError as exc:
+ record(current, "failed", str(exc))
+ except Exception:
+ record(current, "failed", "Compatibility verification could not be completed")
+ finally:
+ await provider.aclose()
+ recorded = {stage["id"] for stage in stages}
+ for stage in stage_ids:
+ if stage not in recorded:
+ record(stage, "not_run", "An earlier stage did not complete")
+ return sorted(stages, key=lambda stage: stage_ids.index(stage["id"]))
diff --git a/core/application/service_lifecycle.py b/core/application/service_lifecycle.py
new file mode 100644
index 000000000..c47a14c5a
--- /dev/null
+++ b/core/application/service_lifecycle.py
@@ -0,0 +1,77 @@
+"""Drain and activity queries over the existing application execution owners."""
+
+from __future__ import annotations
+
+import threading
+import time
+
+from core.application.application import DeepCodeApplication
+from core.domain.turn import TurnStatus
+from core.persistence.execution_repository import TurnRepository
+
+
+class ServiceLifecycle:
+ def __init__(self, application: DeepCodeApplication) -> None:
+ self.application = application
+ self._paused = False
+ self._scheduler_was_active = False
+
+ def resume(self) -> None:
+ """Restore admission after a supervisor could not finish a stop."""
+ if self._paused:
+ self.application.execution_coordinator.resume_admission()
+ if self._scheduler_was_active:
+ self.application.automation_scheduler.start()
+ self._paused = False
+ self._scheduler_was_active = False
+
+ def activity(self) -> dict[str, int | bool]:
+ app = self.application
+ worker_id = app.execution_coordinator.worker_id
+ with app.database.read() as connection:
+ turns = TurnRepository(connection).list_active()
+ owned = [
+ turn
+ for turn in turns
+ if turn.execution_owner_id == worker_id
+ or (turn.execution_owner_id is None and turn.home_worker_id == worker_id)
+ ]
+ return {
+ "activeTurns": sum(turn.status != TurnStatus.QUEUED for turn in owned),
+ "queuedTurns": sum(turn.status == TurnStatus.QUEUED for turn in owned),
+ "terminals": app.terminals.active_count,
+ "schedulerActive": app.automation_scheduler.active,
+ "schedulerLeader": app.automation_scheduler.leader,
+ }
+
+ def drain(self, timeout: float, interrupt: threading.Event) -> bool:
+ """Wait for admitted work, restoring admission if the wait does not finish.
+
+ Queued work stays durable and unstarted. Normal application shutdown
+ settles it using the existing worker/Goal recovery semantics.
+ """
+ app = self.application
+ if self._paused:
+ return True
+ self._scheduler_was_active = app.automation_scheduler.active
+ app.automation_scheduler.close()
+ drained = False
+ try:
+ app.execution_coordinator.pause_admission()
+ self._paused = True
+ deadline = time.monotonic() + timeout
+ while not interrupt.is_set():
+ if (
+ not app.execution_coordinator.active_claims
+ and not app.terminals.active_count
+ ):
+ drained = True
+ return True
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return False
+ interrupt.wait(min(0.05, remaining))
+ return False
+ finally:
+ if not drained:
+ self.resume()
diff --git a/core/application/terminal_service.py b/core/application/terminal_service.py
index ecacd3c6f..f1a23876f 100644
--- a/core/application/terminal_service.py
+++ b/core/application/terminal_service.py
@@ -4,13 +4,16 @@
import codecs
import os
+import select
import signal
import struct
import subprocess
import threading
+import time
import uuid
+from collections import OrderedDict
from collections.abc import Callable
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -22,6 +25,7 @@
ThreadNotFoundError,
)
from core.application.workspace_service import WorkspaceService
+from core.application.views import terminal_info_view
from core.file_lock import FileLease
from core.sessions import SessionStore
@@ -48,9 +52,19 @@ class TerminalInfo:
class _TerminalSession:
info: TerminalInfo
process: subprocess.Popen[bytes]
- master_fd: int
+ master_fd: int | None
activity_lease: FileLease
closing: bool = False
+ output: bytearray = field(default_factory=bytearray)
+ offset: int = 0
+ exited: bool = False
+ exit_code: int | None = None
+ reader: threading.Thread | None = None
+ io_lock: threading.Lock = field(default_factory=threading.Lock)
+
+ @property
+ def head(self) -> int:
+ return self.offset + len(self.output)
class TerminalService:
@@ -60,12 +74,19 @@ def __init__(
sessions: SessionStore,
*,
max_sessions: int = 8,
+ output_capacity: int = 256 * 1024,
+ retained_exits: int = 8,
) -> None:
+ if max_sessions < 1 or output_capacity < 4 or retained_exits < 0:
+ raise ValueError("invalid terminal retention limits")
self.workspaces = workspaces
self.sessions = sessions
self.max_sessions = max_sessions
+ self.output_capacity = output_capacity
+ self.retained_exits = retained_exits
self._lock = threading.RLock()
self._sessions: dict[str, _TerminalSession] = {}
+ self._finished: OrderedDict[str, _TerminalSession] = OrderedDict()
self._listeners: dict[str, TerminalListener] = {}
self._creating = 0
@@ -142,12 +163,22 @@ def create(
self._sessions[terminal_id] = session
self._creating -= 1
registered = True
- threading.Thread(
+ session.reader = threading.Thread(
target=self._read_output,
args=(session,),
name=f"deepcode-terminal-{terminal_id[-8:]}",
daemon=True,
- ).start()
+ )
+ try:
+ session.reader.start()
+ except BaseException:
+ with self._lock:
+ self._sessions.pop(terminal_id, None)
+ self._terminate(process)
+ os.close(master_fd)
+ session.master_fd = None
+ activity_lease.close()
+ raise
return info
except OSError as exc:
raise ConflictError(f"terminal could not start: {exc}") from exc
@@ -169,17 +200,23 @@ def write(self, thread_id: str, terminal_id: str, data: str) -> int:
if len(encoded) > 64 * 1024:
raise InvalidArgumentError("terminal input exceeds 64 KiB")
session = self._owned(thread_id, terminal_id)
- try:
- return os.write(session.master_fd, encoded)
- except OSError as exc:
- raise ConflictError("terminal is no longer writable") from exc
+ with session.io_lock:
+ if session.master_fd is None or session.closing:
+ raise ConflictError("terminal is no longer writable")
+ try:
+ return os.write(session.master_fd, encoded)
+ except OSError as exc:
+ raise ConflictError("terminal is no longer writable") from exc
def resize(
self, thread_id: str, terminal_id: str, *, columns: int, rows: int
) -> TerminalInfo:
self._validate_size(columns, rows)
session = self._owned(thread_id, terminal_id)
- self._set_size(session.master_fd, columns, rows)
+ with session.io_lock:
+ if session.master_fd is None or session.closing:
+ raise ConflictError("terminal has closed")
+ self._set_size(session.master_fd, columns, rows)
with self._lock:
session.info = TerminalInfo(
id=session.info.id,
@@ -198,12 +235,73 @@ def close(self, thread_id: str, terminal_id: str) -> bool:
return False
session.closing = True
self._terminate(session.process)
- try:
- os.close(session.master_fd)
- except OSError:
- pass
return True
+ def list(self, thread_id: str) -> list[dict[str, Any]]:
+ """Discover live terminals and a bounded set of completed output windows."""
+ self._require_thread(thread_id)
+ with self._lock:
+ return [
+ {
+ "terminal": terminal_info_view(session.info),
+ "exited": session.exited,
+ "exitCode": session.exit_code,
+ }
+ for session in (*self._finished.values(), *self._sessions.values())
+ if session.info.thread_id == thread_id
+ ]
+
+ def _require_thread(self, thread_id: str) -> None:
+ if self.sessions.get_session(thread_id) is None:
+ raise ThreadNotFoundError(f"thread not found: {thread_id}")
+
+ def read(
+ self,
+ thread_id: str,
+ terminal_id: str,
+ *,
+ offset: int = 0,
+ limit: int = 16 * 1024,
+ through: int | None = None,
+ ) -> dict[str, Any]:
+ if (
+ offset < 0
+ or not 4 <= limit <= 64 * 1024
+ or (through is not None and through < offset)
+ ):
+ raise InvalidArgumentError("invalid terminal output range")
+ self._require_thread(thread_id)
+ with self._lock:
+ session = self._owned(thread_id, terminal_id, include_finished=True)
+ head = session.head
+ if offset > head:
+ raise InvalidArgumentError("terminal cursor is ahead of its output")
+ start = max(offset, session.offset)
+ # If the original window was evicted during paging, report the new
+ # lower bound even when it has moved beyond the old cutoff.
+ end = max(start, min(head, through if through is not None else head))
+ if end < head and session.output[end - session.offset] & 0xC0 == 0x80:
+ raise InvalidArgumentError("terminal cutoff splits a UTF-8 character")
+ relative = start - session.offset
+ raw = bytes(session.output[relative : relative + min(limit, end - start)])
+ if raw and raw[0] & 0xC0 == 0x80:
+ raise InvalidArgumentError("terminal cursor splits a UTF-8 character")
+ data = raw.decode("utf-8", errors="ignore")
+ next_offset = start + len(data.encode("utf-8"))
+ return {
+ "terminalId": terminal_id,
+ "threadId": thread_id,
+ "data": data,
+ "offset": start,
+ "nextOffset": next_offset,
+ "availableFrom": session.offset,
+ "headOffset": head,
+ "hasMore": next_offset < end,
+ "truncated": offset < session.offset,
+ "exited": session.exited,
+ "exitCode": session.exit_code,
+ }
+
def active_for_thread(self, thread_id: str) -> bool:
with self._lock:
return any(
@@ -211,30 +309,54 @@ def active_for_thread(self, thread_id: str) -> bool:
for session in self._sessions.values()
)
+ @property
+ def active_count(self) -> int:
+ """Number of terminals this application still owns."""
+ with self._lock:
+ return len(self._sessions)
+
def close_all(self) -> None:
with self._lock:
sessions = list(self._sessions.values())
for session in sessions:
- if session.process.poll() is None:
- session.closing = True
- self._terminate(session.process)
- try:
- os.close(session.master_fd)
- except OSError:
- pass
+ session.closing = True
+ self._terminate(session.process)
+ for session in sessions:
+ if (
+ session.reader is not None
+ and session.reader is not threading.current_thread()
+ ):
+ session.reader.join(timeout=3)
+ with self._lock:
+ self._finished.clear()
- def _owned(self, thread_id: str, terminal_id: str) -> _TerminalSession:
+ def _owned(
+ self, thread_id: str, terminal_id: str, *, include_finished: bool = False
+ ) -> _TerminalSession:
with self._lock:
session = self._sessions.get(terminal_id)
+ if session is None and include_finished:
+ session = self._finished.get(terminal_id)
if session is None or session.info.thread_id != thread_id:
raise TerminalNotFoundError(f"terminal not found for Thread: {terminal_id}")
return session
def _read_output(self, session: _TerminalSession) -> None:
decoder = codecs.getincrementaldecoder("utf-8")("replace")
+ close_deadline = None
try:
while True:
+ if session.closing:
+ if close_deadline is None:
+ close_deadline = time.monotonic() + 0.75
+ if time.monotonic() >= close_deadline:
+ break
try:
+ ready, _, _ = select.select([session.master_fd], [], [], 0.1)
+ if not ready:
+ if session.closing:
+ break
+ continue
raw = os.read(session.master_fd, 16 * 1024)
except OSError:
break
@@ -242,33 +364,30 @@ def _read_output(self, session: _TerminalSession) -> None:
break
text = decoder.decode(raw)
if text:
- self._publish(
- "terminal.output",
- {
- "terminalId": session.info.id,
- "threadId": session.info.thread_id,
- "data": text,
- },
- )
+ self._record_output(session, text)
trailing = decoder.decode(b"", final=True)
if trailing:
- self._publish(
- "terminal.output",
- {
- "terminalId": session.info.id,
- "threadId": session.info.thread_id,
- "data": trailing,
- },
- )
+ self._record_output(session, trailing)
finally:
+ # The reader exclusively closes the descriptor, including natural
+ # exit. Producer operations use io_lock to avoid descriptor reuse.
+ with session.io_lock:
+ if session.master_fd is not None:
+ os.close(session.master_fd)
+ session.master_fd = None
try:
exit_code = session.process.wait(timeout=1)
except subprocess.TimeoutExpired:
self._terminate(session.process)
exit_code = session.process.returncode
with self._lock:
+ session.exited = True
+ session.exit_code = exit_code
if self._sessions.get(session.info.id) is session:
self._sessions.pop(session.info.id, None)
+ self._finished[session.info.id] = session
+ while len(self._finished) > self.retained_exits:
+ self._finished.popitem(last=False)
session.activity_lease.close()
self._publish(
"terminal.exit",
@@ -276,9 +395,33 @@ def _read_output(self, session: _TerminalSession) -> None:
"terminalId": session.info.id,
"threadId": session.info.thread_id,
"exitCode": exit_code,
+ "nextOffset": session.head,
},
)
+ def _record_output(self, session: _TerminalSession, text: str) -> None:
+ encoded = text.encode("utf-8")
+ with self._lock:
+ offset = session.head
+ session.output.extend(encoded)
+ trim = max(0, len(session.output) - self.output_capacity)
+ while trim < len(session.output) and session.output[trim] & 0xC0 == 0x80:
+ trim += 1
+ if trim:
+ del session.output[:trim]
+ session.offset += trim
+ next_offset = session.head
+ self._publish(
+ "terminal.output",
+ {
+ "terminalId": session.info.id,
+ "threadId": session.info.thread_id,
+ "data": text,
+ "offset": offset,
+ "nextOffset": next_offset,
+ },
+ )
+
def _publish(self, method: str, payload: dict[str, Any]) -> None:
with self._lock:
listeners = tuple(self._listeners.values())
diff --git a/core/application/thread_service.py b/core/application/thread_service.py
index 8c7ead3db..cb959fe37 100644
--- a/core/application/thread_service.py
+++ b/core/application/thread_service.py
@@ -61,6 +61,15 @@
_UNSET = object()
+def _visible_conversation_items(items: list[Item]) -> list[Item]:
+ """Match the canonical text conversation, retaining tool-only timeline items.
+
+ An assistant record carrying toolCalls can have no text. Its reconstructed
+ item belongs to the timeline, but neither side counts it as spoken text.
+ """
+ return [item for item in items if str(item.payload.get("text", item.summary))]
+
+
def _projected_item_kind(message: SessionMessage) -> ItemKind:
"""The item kind a rebuilt-from-JSONL record should carry.
@@ -85,7 +94,22 @@ def _projected_item_kind(message: SessionMessage) -> ItemKind:
def _projected_item_payload(message: SessionMessage) -> dict[str, object]:
"""Payload matching what the live projection stores for the same kind."""
if message.role != "tool":
- return {"text": message.content, "projectedFromSession": True}
+ payload = {"text": message.content, "projectedFromSession": True}
+ if message.role == "user":
+ # Keep admission receipts when rebuilding disposable SQLite state.
+ metadata = message.metadata or {}
+ for key in (
+ "messageId",
+ "requestFingerprint",
+ "expectedTurnId",
+ "deliveryState",
+ "source",
+ "delivery",
+ "client",
+ ):
+ if isinstance(metadata.get(key), str):
+ payload[key] = metadata[key]
+ return payload
metadata = message.metadata or {}
name = str(metadata.get("name") or "tool")
return {
@@ -781,7 +805,9 @@ def _reconcile_transcript(
if message.role in {"user", "assistant"} and message.content
]
items = ItemRepository(connection)
- projected_items = items.conversation_for_thread(thread.id)
+ projected_items = _visible_conversation_items(
+ items.conversation_for_thread(thread.id)
+ )
projected = [
(
"user" if item.kind is ItemKind.USER_MESSAGE else "assistant",
@@ -1381,7 +1407,12 @@ def _is_context_note(message: SessionMessage) -> bool:
projection conflict.
"""
metadata = message.metadata or {}
- return "delivery" in metadata
+ # User input also carries delivery provenance (current_turn/next_turn).
+ # Only the context-note sink's markers identify internal user-role notes.
+ return message.role == "user" and metadata.get("delivery") in (
+ "mid_turn",
+ "between_turns",
+ )
def _merge_projection_tail(
self,
@@ -1390,6 +1421,7 @@ def _merge_projection_tail(
*,
projection_thread_id: str,
) -> bool:
+ projected_items = _visible_conversation_items(projected_items)
canonical_pairs = [
(message.role, message.content)
for message in canonical.messages
diff --git a/core/application/turn_input_service.py b/core/application/turn_input_service.py
index 4f552910c..eb320ee19 100644
--- a/core/application/turn_input_service.py
+++ b/core/application/turn_input_service.py
@@ -3,7 +3,8 @@
from __future__ import annotations
from collections.abc import Callable
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
+import logging
from core.agent_runtime.injections import (
GoalObjectiveUpdated,
@@ -18,6 +19,8 @@
DuplicateMessageConflictError,
EmptyInputError,
ExpectedTurnMismatchError,
+ InputDeliveryPendingError,
+ InputDeliveryUncertainError,
InputTooLargeError,
NoActiveTurnError,
ThreadNotFoundError,
@@ -32,6 +35,7 @@
from core.domain.item import Item, ItemKind, ItemStatus
from core.domain.message_provenance import (
ClientSurface,
+ InputDeliveryState,
TurnInputDelivery,
TurnInputSource,
)
@@ -43,6 +47,7 @@
from core.sessions import SessionStore
EventPublisher = Callable[[list[DomainEvent]], None]
+logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
@@ -91,6 +96,7 @@ def steer(
thread_id,
prompt=clean_prompt,
message_id=clean_message_id,
+ expected_turn_id=clean_expected,
)
if duplicate is not None:
return duplicate
@@ -135,23 +141,52 @@ def steer(
) from exc
if reservation is None:
- return TurnInputReceipt(
+ duplicate = self._persisted_input(
+ thread_id,
+ prompt=clean_prompt,
message_id=clean_message_id,
- delivery=TurnInputDelivery.CURRENT_TURN.value,
- turn=executing,
- duplicate=True,
+ expected_turn_id=clean_expected,
+ )
+ if duplicate is not None:
+ return duplicate
+ raise InputDeliveryUncertainError(
+ "mailbox input has no durable delivery confirmation",
+ details={
+ "threadId": thread_id,
+ "expectedTurnId": clean_expected,
+ "messageId": clean_message_id,
+ },
)
+ item = None
try:
- self._persist_live_input(
+ item, events = self._record_input_intent(
executing,
prompt=clean_prompt,
message_id=clean_message_id,
client_surface=client_surface,
)
+ self._publish(events)
+ self._append_canonical_input(item)
self.session_runtimes.commit_input(thread_id, reservation)
- except BaseException:
+ self._set_delivery_state(item.id, InputDeliveryState.ACCEPTED)
+ except BaseException as exc:
self.session_runtimes.cancel_input(thread_id, reservation)
+ if item is not None:
+ try:
+ self._set_delivery_state(item.id, InputDeliveryState.UNKNOWN)
+ except Exception:
+ logger.exception("Could not settle Steer receipt %s", item.id)
+ if isinstance(exc, Exception):
+ raise InputDeliveryUncertainError(
+ "Steer delivery could not be confirmed; query its original receipt",
+ details={
+ "threadId": thread_id,
+ "expectedTurnId": clean_expected,
+ "messageId": clean_message_id,
+ "itemId": item.id,
+ },
+ ) from exc
raise
return TurnInputReceipt(
message_id=clean_message_id,
@@ -217,19 +252,16 @@ def _persisted_input(
*,
prompt: str,
message_id: str,
+ expected_turn_id: str,
) -> TurnInputReceipt | None:
with self.database.read() as connection:
- if ThreadRepository(connection).get(thread_id) is None:
- raise ThreadNotFoundError(f"thread not found: {thread_id}")
- item = ItemRepository(connection).find_user_message_by_message_id(
- thread_id,
- message_id,
- )
+ item = self._find_input(connection, thread_id, message_id)
if item is None:
return None
if (
item.payload.get("text") != prompt
or item.payload.get("source") != TurnInputSource.STEER.value
+ or item.payload.get("expectedTurnId", item.turn_id) != expected_turn_id
):
raise DuplicateMessageConflictError(
"messageId was already used with different content"
@@ -239,6 +271,25 @@ def _persisted_input(
raise DuplicateMessageConflictError(
"idempotent input references a missing Turn"
)
+ state = self._delivery_state(item, turn)
+ if state is not InputDeliveryState.ACCEPTED:
+ error = (
+ InputDeliveryPendingError
+ if state is InputDeliveryState.PENDING
+ else InputDeliveryUncertainError
+ )
+ raise error(
+ "Steer delivery is pending confirmation"
+ if state is InputDeliveryState.PENDING
+ else "Steer delivery is uncertain; this input will not be submitted again",
+ details={
+ "threadId": thread_id,
+ "expectedTurnId": expected_turn_id,
+ "messageId": message_id,
+ "itemId": item.id,
+ "deliveryState": state.value,
+ },
+ )
return TurnInputReceipt(
message_id=message_id,
delivery=TurnInputDelivery.CURRENT_TURN.value,
@@ -246,14 +297,56 @@ def _persisted_input(
duplicate=True,
)
- def _persist_live_input(
+ def read(self, thread_id: str, message_id: str) -> Item | None:
+ """Locate an admitted input after a lost response without submitting work."""
+ message_id = message_id.strip()
+ if not message_id:
+ raise EmptyInputError("messageId must not be empty")
+ with self.database.read() as connection:
+ item = self._find_input(connection, thread_id, message_id)
+ if (
+ item is None
+ or item.payload.get("source") != TurnInputSource.STEER.value
+ ):
+ return item
+ turn = TurnRepository(connection).get(item.turn_id)
+ return replace(
+ item,
+ payload={
+ **item.payload,
+ "deliveryState": self._delivery_state(item, turn).value,
+ },
+ )
+
+ @staticmethod
+ def _delivery_state(item: Item, turn: Turn | None) -> InputDeliveryState:
+ state = item.payload.get("deliveryState")
+ if state == InputDeliveryState.ACCEPTED.value:
+ return InputDeliveryState.ACCEPTED
+ if (
+ state == InputDeliveryState.PENDING.value
+ and turn is not None
+ and not turn.status.is_terminal
+ ):
+ return InputDeliveryState.PENDING
+ return InputDeliveryState.UNKNOWN
+
+ @staticmethod
+ def _find_input(connection, thread_id: str, message_id: str) -> Item | None:
+ if ThreadRepository(connection).get(thread_id) is None:
+ raise ThreadNotFoundError(f"thread not found: {thread_id}")
+ return ItemRepository(connection).find_user_message_by_message_id(
+ thread_id, message_id
+ )
+
+ def _record_input_intent(
self,
turn: Turn,
*,
prompt: str,
message_id: str,
client_surface: ClientSurface,
- ) -> None:
+ ) -> tuple[Item, list[DomainEvent]]:
now = utc_now()
events: list[DomainEvent] = []
with self.database.transaction() as connection:
@@ -281,6 +374,11 @@ def _persist_live_input(
)
items = ItemRepository(connection)
+ if (
+ items.find_user_message_by_message_id(turn.thread_id, message_id)
+ is not None
+ ):
+ raise DuplicateMessageConflictError("messageId was already recorded")
item = Item(
thread_id=turn.thread_id,
turn_id=turn.id,
@@ -294,54 +392,113 @@ def _persist_live_input(
"client": client_surface.value,
"delivery": TurnInputDelivery.CURRENT_TURN.value,
"source": TurnInputSource.STEER.value,
+ "expectedTurnId": turn.id,
+ "deliveryState": InputDeliveryState.PENDING.value,
},
created_at=now,
updated_at=now,
)
items.add(item)
event_repo = EventRepository(connection)
- events.extend(
- (
- event_repo.append(
- thread_id=turn.thread_id,
- turn_id=turn.id,
- item_id=item.id,
- type="item.created",
- payload={"item": item_view(item)},
- ),
- event_repo.append(
- thread_id=turn.thread_id,
- turn_id=turn.id,
- item_id=item.id,
- type="turn.steered",
- payload={
- "turnId": turn.id,
- "messageId": message_id,
- "delivery": TurnInputDelivery.CURRENT_TURN.value,
- },
- ),
+ events.append(
+ event_repo.append(
+ thread_id=turn.thread_id,
+ turn_id=turn.id,
+ item_id=item.id,
+ type="item.created",
+ payload={"item": item_view(item)},
)
)
- self._publish(events)
+ return item, events
+ def _append_canonical_input(self, item: Item) -> None:
stored = self.session_store.append_message(
- turn.thread_id,
+ item.thread_id,
"user",
- prompt,
+ item.payload["text"],
metadata={
"schemaVersion": 3,
- "client": client_surface.value,
- "turnId": turn.id,
- "messageId": message_id,
+ "client": item.payload["client"],
+ "turnId": item.turn_id,
+ "messageId": item.payload["messageId"],
"delivery": TurnInputDelivery.CURRENT_TURN.value,
"source": TurnInputSource.STEER.value,
+ "expectedTurnId": item.turn_id,
+ # A transcript proves persistence, not mailbox acceptance. A
+ # rebuilt projection must not fabricate delivery confirmation.
+ "deliveryState": InputDeliveryState.UNKNOWN.value,
},
)
if stored is None:
raise ThreadNotFoundError(
- f"canonical session disappeared: {turn.thread_id}"
+ f"canonical session disappeared: {item.thread_id}"
)
- self.session_runtimes.mark_persisted(turn.thread_id)
+ self.session_runtimes.mark_persisted(item.thread_id)
+
+ def _set_delivery_state(self, item_id: str, state: InputDeliveryState) -> None:
+ with self.database.transaction() as connection:
+ items = ItemRepository(connection)
+ item = items.get(item_id)
+ if item is None:
+ raise InputDeliveryUncertainError("Steer receipt disappeared")
+ if item.payload.get("deliveryState") == InputDeliveryState.ACCEPTED.value:
+ return
+ events = self._update_delivery_state(connection, item, state)
+ if state is InputDeliveryState.ACCEPTED:
+ events.append(
+ EventRepository(connection).append(
+ thread_id=item.thread_id,
+ turn_id=item.turn_id,
+ item_id=item.id,
+ type="turn.steered",
+ payload={
+ "turnId": item.turn_id,
+ "messageId": item.payload["messageId"],
+ "delivery": TurnInputDelivery.CURRENT_TURN.value,
+ "deliveryState": state.value,
+ },
+ )
+ )
+ self._publish(events)
+
+ @staticmethod
+ def _update_delivery_state(
+ connection, item: Item, state: InputDeliveryState
+ ) -> list[DomainEvent]:
+ if item.payload.get("deliveryState") == state.value:
+ return []
+ updated = replace(
+ item,
+ payload={**item.payload, "deliveryState": state.value},
+ updated_at=utc_now(),
+ )
+ ItemRepository(connection).update(updated)
+ return [
+ EventRepository(connection).append(
+ thread_id=item.thread_id,
+ turn_id=item.turn_id,
+ item_id=item.id,
+ type="item.updated",
+ payload={"item": item_view(updated)},
+ )
+ ]
+
+ def settle_pending(self, connection, turn_id: str) -> list[DomainEvent]:
+ """A terminating Turn cannot leave an unconfirmed input pending forever."""
+ events = []
+ for item in ItemRepository(connection).list_for_turn(turn_id):
+ if (
+ item.kind is ItemKind.USER_MESSAGE
+ and item.payload.get("source") == TurnInputSource.STEER.value
+ and item.payload.get("deliveryState")
+ == InputDeliveryState.PENDING.value
+ ):
+ events.extend(
+ self._update_delivery_state(
+ connection, item, InputDeliveryState.UNKNOWN
+ )
+ )
+ return events
__all__ = ["TurnInputReceipt", "TurnInputService"]
diff --git a/core/application/turn_service.py b/core/application/turn_service.py
index 14f231816..919573640 100644
--- a/core/application/turn_service.py
+++ b/core/application/turn_service.py
@@ -51,6 +51,7 @@
GoalTurnAssociation,
)
from core.application.llm_configuration_service import LLMConfigurationService
+from core.application.input_identity import submission_fingerprint
from core.application.session_runtime import SessionRuntimeRegistry
from core.application.turn_input_service import (
TurnInputReceipt,
@@ -350,6 +351,7 @@ def start(
queue_if_busy=False,
event_observer=event_observer,
input_message_id=message_id,
+ requested_skill_ids=skill_ids,
client_surface=client_surface,
input_source=input_source,
input_delivery=TurnInputDelivery.CURRENT_TURN,
@@ -455,6 +457,7 @@ def enqueue(
queue_if_busy=True,
event_observer=event_observer,
input_message_id=message_id,
+ requested_skill_ids=skill_ids,
client_surface=client_surface,
input_source=TurnInputSource.QUEUE,
input_delivery=TurnInputDelivery.NEXT_TURN,
@@ -485,6 +488,7 @@ def _submit(
goal_id: str | None = None,
goal_turn_settlement_ids: frozenset[str] = frozenset(),
input_message_id: str | None = None,
+ requested_skill_ids: tuple[str, ...] | None = None,
client_surface: ClientSurface = ClientSurface.INTERNAL,
input_source: TurnInputSource = TurnInputSource.START,
input_delivery: TurnInputDelivery = TurnInputDelivery.CURRENT_TURN,
@@ -509,6 +513,24 @@ def _submit(
)
except (TypeError, ValueError) as exc:
raise InvalidArgumentError(str(exc)) from exc
+ fingerprint = (
+ submission_fingerprint(
+ prompt=clean_prompt,
+ skill_ids=requested_skill_ids
+ if requested_skill_ids is not None
+ else clean_skill_ids,
+ connection_id=connection_id,
+ model=model,
+ reasoning_effort=reasoning_effort,
+ source=input_source,
+ delivery=input_delivery,
+ execution_class=execution_class,
+ security_override=execution_security_profile_override,
+ permission_override=execution_permission_mode_override,
+ )
+ if input_message_id is not None
+ else None
+ )
events: list[DomainEvent] = []
schedule_now = False
participant_contribution: (
@@ -565,6 +587,11 @@ def _submit(
if (
existing_item.payload.get("text") != clean_prompt
or existing_item.payload.get("source") != input_source.value
+ or (
+ existing_item.payload.get("requestFingerprint") is not None
+ and existing_item.payload["requestFingerprint"]
+ != fingerprint
+ )
):
raise DuplicateMessageConflictError(
"messageId was already used with different content"
@@ -574,6 +601,23 @@ def _submit(
raise DuplicateMessageConflictError(
"idempotent input references a missing Turn"
)
+ if existing_item.payload.get("requestFingerprint") is None:
+ # Older receipts did not preserve the requested selection.
+ # Check supplied selectors against their execution snapshot;
+ # omitted defaults continue to refer to the original Turn.
+ profile = existing_turn.execution_profile
+ if clean_skill_ids != existing_turn.skill_ids or any(
+ value is not None
+ and (profile is None or value != getattr(profile, name))
+ for value, name in (
+ (connection_id, "connection_id"),
+ (model, "model_id"),
+ (reasoning_effort, "reasoning_effort"),
+ )
+ ):
+ raise DuplicateMessageConflictError(
+ "messageId was already used with a different selection"
+ )
return _TurnSubmission(
TurnSnapshot(
existing_turn,
@@ -653,7 +697,7 @@ def _submit(
"delivery": input_delivery.value,
"source": input_source.value,
**(
- {"messageId": input_message_id}
+ {"messageId": input_message_id, "requestFingerprint": fingerprint}
if input_message_id is not None
else {}
),
@@ -1051,13 +1095,22 @@ def executing_for_thread(self, thread_id: str) -> Turn | None:
raise ThreadNotFoundError(f"thread not found: {thread_id}")
return TurnRepository(connection).executing_for_thread(thread_id)
- def list_for_thread(self, thread_id: str) -> tuple[Turn, ...]:
+ def list_for_thread(
+ self,
+ thread_id: str,
+ *,
+ limit: int | None = None,
+ offset: int = 0,
+ state: str = "all",
+ ) -> tuple[Turn, ...]:
"""Return the durable Turn queue in ordinal order for client status UI."""
with self.database.read() as connection:
if ThreadRepository(connection).get(thread_id) is None:
raise ThreadNotFoundError(f"thread not found: {thread_id}")
- turns = TurnRepository(connection).list_for_thread(thread_id)
+ turns = TurnRepository(connection).list_for_thread(
+ thread_id, limit=limit, offset=offset, state=state
+ )
return tuple(turns)
def list_for_goal(self, thread_id: str, goal_id: str) -> tuple[Turn, ...]:
@@ -1079,6 +1132,9 @@ def may_resume_queued_after_restart(self, turn: Turn) -> bool:
for item in ItemRepository(connection).list_for_turn(turn.id)
)
+ def read_input(self, thread_id: str, message_id: str) -> Item | None:
+ return self.turn_inputs.read(thread_id, message_id)
+
def steer(
self,
thread_id: str,
@@ -1331,7 +1387,13 @@ async def approve(
initial_input_metadata = (
{
key: initial_item.payload[key]
- for key in ("messageId", "client", "delivery", "source")
+ for key in (
+ "messageId",
+ "requestFingerprint",
+ "client",
+ "delivery",
+ "source",
+ )
if key in initial_item.payload
}
if initial_item is not None
@@ -1839,6 +1901,7 @@ def _finish(
payload={"turnId": turn_id},
)
)
+ events.extend(self.turn_inputs.settle_pending(connection, turn_id))
if recover_active:
approvals = ApprovalRepository(connection)
for approval in approvals.pending_for_turn(turn_id):
@@ -2035,6 +2098,7 @@ def recover_incomplete(
if turn.status is TurnStatus.QUEUED and should_resume(turn):
continue
now = utc_now()
+ events.extend(self.turn_inputs.settle_pending(connection, turn.id))
for approval in approvals.pending_for_turn(turn.id):
cancelled = replace(
approval,
diff --git a/core/application/workflow_service.py b/core/application/workflow_service.py
index 4445628a2..fab6dc2fc 100644
--- a/core/application/workflow_service.py
+++ b/core/application/workflow_service.py
@@ -68,7 +68,6 @@
from core.persistence.workflow_repository import ArtifactRepository, WorkflowRepository
from core.sessions import SessionStore
-
SUPPORTED_KINDS = frozenset({"paper2code"})
SUPPORTED_SOURCE_TYPES = frozenset({"local", "url", "repository", "requirement"})
MAX_SOURCE_LENGTH = 16_384
@@ -853,28 +852,32 @@ async def _execute(
load_config_for_workspace(workspace),
credential_store=self.llm_configuration.credentials,
phase_execution_profiles=_execution_profiles(run.input),
+ config_loader=lambda: load_config_for_workspace(workspace),
)
- with use_runtime(runtime):
- outcome = await self.runner.run(
- request,
- WorkflowCallbacks(
- progress=lambda stage, current, total, message, metadata: (
- self._progress(
+ try:
+ with use_runtime(runtime):
+ outcome = await self.runner.run(
+ request,
+ WorkflowCallbacks(
+ progress=lambda stage, current, total, message, metadata: (
+ self._progress(
+ run.id,
+ stage=stage,
+ current=current,
+ total=total,
+ message=message,
+ metadata=metadata,
+ )
+ ),
+ interact=lambda interaction: self._interact(
run.id,
- stage=stage,
- current=current,
- total=total,
- message=message,
- metadata=metadata,
- )
- ),
- interact=lambda interaction: self._interact(
- run.id,
- interaction,
- claim=claim,
+ interaction,
+ claim=claim,
+ ),
),
- ),
- )
+ )
+ finally:
+ await runtime.aclose()
if outcome.status == "completed":
self._finish(
run.id,
diff --git a/core/compat/runtime.py b/core/compat/runtime.py
index 1e83e67a0..5162ee1e0 100644
--- a/core/compat/runtime.py
+++ b/core/compat/runtime.py
@@ -17,9 +17,10 @@
from __future__ import annotations
+import asyncio
import hashlib
import threading
-from collections.abc import Iterator
+from collections.abc import Callable, Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
@@ -39,7 +40,6 @@
from core.providers.credentials import CredentialStore
from core.providers.profiles import ConnectionResolver
-
_runtime_lock = threading.Lock()
_runtime: "DeepCodeRuntime | None" = None
@@ -74,14 +74,18 @@ def __init__(
*,
credential_store: CredentialStore | None = None,
phase_execution_profiles: dict[str, ExecutionProfile] | None = None,
+ config_loader: Callable[[], DeepCodeConfig] | None = None,
) -> None:
self.config = config
self.credential_store = credential_store or CredentialStore()
- self.connection_resolver = ConnectionResolver(config, self.credential_store)
+ self.connection_resolver = ConnectionResolver(
+ config, self.credential_store, config_loader=config_loader
+ )
self.model_catalog = ModelCatalogService()
self.phase_execution_profiles = dict(phase_execution_profiles or {})
self.logger = logger
self._provider_cache: dict[tuple[str, ...], LLMProvider] = {}
+ self._closed = False
# MCP servers materialised on construction so legacy callers can
# mutate ``args`` in place (workflows.environment, plugin code, ...).
self._mcp_servers = config.mcp_servers
@@ -92,7 +96,10 @@ def __init__(
@classmethod
def load(cls, config_path: str | None = None) -> "DeepCodeRuntime":
"""Read ``deepcode_config.json`` and build a fresh runtime."""
- return cls(load_config(config_path=config_path))
+ return cls(
+ load_config(config_path=config_path),
+ config_loader=lambda: load_config(config_path=config_path),
+ )
def provider_for(
self,
@@ -104,6 +111,8 @@ def provider_for(
execution_profile: ExecutionProfile | None = None,
) -> LLMProvider:
"""Return a cached :class:`LLMProvider` for the requested combination."""
+ if self._closed:
+ raise RuntimeError("The provider runtime is closed")
if execution_profile is None and connection_id is None and model is None:
execution_profile = self.phase_execution_profiles.get(phase)
if execution_profile is not None or connection_id is not None:
@@ -120,6 +129,10 @@ def provider_for(
profile.connection_id,
profile.model_id,
profile.config_revision,
+ profile.provider_revision or "",
+ repr(profile.input_modalities),
+ repr(profile.tool_calling),
+ repr(profile.reasoning_supported),
credential_revision,
str(profile.max_tokens),
repr(profile.temperature),
@@ -148,6 +161,31 @@ def provider_for(
self._provider_cache[cache_key] = provider
return provider
+ async def aclose(self) -> None:
+ """Close only this runtime's pools after its sessions and children finish."""
+ self._closed = True
+ providers = list(
+ {
+ id(provider): provider for provider in self._provider_cache.values()
+ }.values()
+ )
+ results = await asyncio.gather(
+ *(provider.aclose() for provider in providers), return_exceptions=True
+ )
+ failed = {
+ id(provider)
+ for provider, result in zip(providers, results, strict=True)
+ if isinstance(result, BaseException)
+ }
+ self._provider_cache = {
+ key: provider
+ for key, provider in self._provider_cache.items()
+ if id(provider) in failed
+ }
+ errors = [result for result in results if isinstance(result, BaseException)]
+ if errors:
+ raise BaseExceptionGroup("Provider cleanup failed", errors)
+
def resolve_execution_profile(
self,
*,
diff --git a/core/config.py b/core/config.py
index 023453394..4126bd723 100644
--- a/core/config.py
+++ b/core/config.py
@@ -36,13 +36,18 @@
from typing import Any, Literal
from loguru import logger
-from pydantic import AliasChoices, BaseModel, ConfigDict, Field
+from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings
from core.agent_runtime.tools.mcp import MCPServerConfig
from core.mcp.models import McpServerDefinition
from core.providers.base import GenerationSettings, LLMProvider
+from core.providers.protocol_config import (
+ ProviderCompat,
+ ProviderProtocol,
+ protocol_adapter,
+)
from core.providers.registry import (
PROVIDERS,
ProviderSpec,
@@ -67,6 +72,7 @@ class _Base(BaseModel):
alias_generator=to_camel,
populate_by_name=True,
extra="ignore",
+ hide_input_in_errors=True,
)
@@ -145,6 +151,18 @@ class ProviderConfig(_Base):
api_key: str | None = None
api_base: str | None = None
extra_headers: dict[str, str] | None = None
+ protocol: ProviderProtocol = "auto"
+ compat: ProviderCompat = Field(default_factory=ProviderCompat)
+ auth: Literal["api_key", "none"] = "api_key"
+
+ @model_validator(mode="after")
+ def validate_wire(self):
+ self.compat.validate_protocol(self.protocol)
+ if self.auth == "none" and self.protocol == "anthropic_messages":
+ raise ValueError(
+ "Unauthenticated endpoints currently require an OpenAI protocol"
+ )
+ return self
class ManualModelConfig(_Base):
@@ -167,6 +185,19 @@ class ManualModelConfig(_Base):
context_window: int | None = None
max_output_tokens: int | None = None
reasoning_efforts: list[str] | Literal[False] | None = None
+ input_modalities: list[Literal["text", "image"]] | None = Field(
+ default=None, min_length=1, max_length=2
+ )
+ tool_calling: bool | None = None
+ compat: ProviderCompat = Field(default_factory=ProviderCompat)
+
+ @model_validator(mode="after")
+ def validate_modalities(self):
+ if self.input_modalities is not None and len(set(self.input_modalities)) != len(
+ self.input_modalities
+ ):
+ raise ValueError("Input modalities must be unique")
+ return self
class ConnectionProfileConfig(_Base):
@@ -179,6 +210,9 @@ class ConnectionProfileConfig(_Base):
label: str = ""
template: str = "custom"
adapter: Literal["openai_compat", "anthropic"] | None = None
+ protocol: ProviderProtocol = "auto"
+ auth: Literal["api_key", "none", "oauth"] = "api_key"
+ compat: ProviderCompat = Field(default_factory=ProviderCompat)
api_base: str | None = None
api_key_env: str | None = None
extra_headers: dict[str, str] = Field(default_factory=dict)
@@ -188,6 +222,48 @@ class ConnectionProfileConfig(_Base):
manual_models: list[str | ManualModelConfig] = Field(default_factory=list)
enabled: bool = True
+ @model_validator(mode="after")
+ def validate_wire(self):
+ spec = find_by_name(self.template)
+ legacy = self.adapter or (spec.backend if spec else "openai_compat")
+ effective = protocol_adapter(self.protocol, legacy)
+ if (
+ self.protocol != "auto"
+ and self.adapter is not None
+ and self.adapter != effective
+ ):
+ raise ValueError(
+ "Explicit protocol and legacy adapter disagree; clear the adapter or select the matching protocol"
+ )
+ if self.auth == "none" and effective == "anthropic":
+ raise ValueError(
+ "Unauthenticated endpoints currently require an OpenAI protocol"
+ )
+ if self.auth == "oauth" and (
+ self.template != "openrouter"
+ or self.protocol not in {"auto", "openai_chat"}
+ or effective != "openai_compat"
+ or self.api_base
+ not in {
+ None,
+ "https://openrouter.ai/api/v1",
+ "https://openrouter.ai/api/v1/",
+ }
+ or self.api_key_env
+ or any(
+ key.lower() in {"authorization", "x-api-key"}
+ for key in self.extra_headers
+ )
+ ):
+ raise ValueError(
+ "OAuth currently requires the official OpenRouter Chat endpoint without credential overrides"
+ )
+ self.compat.validate_protocol(self.protocol)
+ for model in self.manual_models:
+ if isinstance(model, ManualModelConfig):
+ model.compat.validate_protocol(self.protocol)
+ return self
+
class ProvidersConfig(_Base):
"""Per-provider connection blocks. Add new providers by extending here
@@ -879,12 +955,18 @@ def make_llm_provider(
"Set agents.defaults.provider or fill in the matching providers..apiKey."
)
- backend = spec.backend
+ protocol = provider_cfg.protocol if provider_cfg else "auto"
+ backend = protocol_adapter(protocol, spec.backend)
api_key = provider_cfg.api_key if provider_cfg else None
api_base = provider_cfg.api_base if provider_cfg else None
extra_headers = provider_cfg.extra_headers if provider_cfg else None
- needs_key = not (spec.is_oauth or spec.is_local or spec.is_direct)
+ auth_mode = provider_cfg.auth if provider_cfg else "api_key"
+ if auth_mode == "none" and backend == "anthropic":
+ raise ConfigError("Anthropic Messages requires an API key")
+ needs_key = auth_mode != "none" and not (
+ spec.is_oauth or spec.is_local or spec.is_direct
+ )
if needs_key and not api_key:
raise ConfigError(
f"Provider '{spec.name}' (phase '{phase}') requires providers.{spec.name}.apiKey "
@@ -901,6 +983,7 @@ def make_llm_provider(
api_base=effective_base,
default_model=chosen_model,
extra_headers=extra_headers,
+ compat=provider_cfg.compat if provider_cfg else None,
)
elif backend == "openai_compat":
from core.providers.openai_compat import OpenAICompatProvider
@@ -911,6 +994,9 @@ def make_llm_provider(
default_model=chosen_model,
extra_headers=extra_headers,
spec=spec,
+ protocol=protocol,
+ compat=provider_cfg.compat if provider_cfg else None,
+ auth_mode=auth_mode,
)
else:
raise ValueError(
diff --git a/core/domain/execution_profile.py b/core/domain/execution_profile.py
index 01e3f4d2b..8f0ba8e9b 100644
--- a/core/domain/execution_profile.py
+++ b/core/domain/execution_profile.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import math
+import re
from dataclasses import dataclass
from typing import Any
@@ -58,8 +59,35 @@ class ExecutionProfile:
temperature: float
reasoning_effort: str | None
config_revision: str
+ protocol: str = "auto"
+ provider_revision: str | None = None
+ input_modalities: tuple[str, ...] | None = None
+ tool_calling: bool | None = None
+ reasoning_supported: bool | None = None
def __post_init__(self) -> None:
+ if self.provider_revision is not None and (
+ not isinstance(self.provider_revision, str)
+ or not re.fullmatch(r"[a-f0-9]{64}", self.provider_revision)
+ ):
+ raise ValueError("invalid provider revision")
+ if self.reasoning_supported is not None and not isinstance(
+ self.reasoning_supported, bool
+ ):
+ raise ValueError("reasoning_supported must be a boolean")
+ if self.protocol not in {
+ "auto",
+ "openai_chat",
+ "openai_responses",
+ "anthropic_messages",
+ }:
+ raise ValueError("invalid provider protocol")
+ if self.input_modalities is not None and (
+ not self.input_modalities or set(self.input_modalities) - {"text", "image"}
+ ):
+ raise ValueError("invalid input modalities")
+ if self.tool_calling is not None and not isinstance(self.tool_calling, bool):
+ raise ValueError("tool_calling must be a boolean")
for value, name in (
(self.connection_id, "connection_id"),
(self.provider_name, "provider_name"),
@@ -90,6 +118,27 @@ def to_dict(self) -> dict[str, Any]:
"temperature": self.temperature,
"reasoningEffort": self.reasoning_effort,
"configRevision": self.config_revision,
+ **({"protocol": self.protocol} if self.protocol != "auto" else {}),
+ **(
+ {"providerRevision": self.provider_revision}
+ if self.provider_revision is not None
+ else {}
+ ),
+ **(
+ {"inputModalities": list(self.input_modalities)}
+ if self.input_modalities is not None
+ else {}
+ ),
+ **(
+ {"toolCalling": self.tool_calling}
+ if self.tool_calling is not None
+ else {}
+ ),
+ **(
+ {"reasoningSupported": self.reasoning_supported}
+ if self.reasoning_supported is not None
+ else {}
+ ),
}
@classmethod
@@ -114,6 +163,13 @@ def from_dict(cls, value: Any) -> "ExecutionProfile | None":
else None
),
config_revision=str(value["configRevision"]),
+ protocol=value.get("protocol", "auto"),
+ provider_revision=value.get("providerRevision"),
+ input_modalities=tuple(value["inputModalities"])
+ if value.get("inputModalities") is not None
+ else None,
+ tool_calling=value.get("toolCalling"),
+ reasoning_supported=value.get("reasoningSupported"),
)
except (KeyError, TypeError, ValueError):
return None
diff --git a/core/domain/message_provenance.py b/core/domain/message_provenance.py
index f7988a7d0..10628f831 100644
--- a/core/domain/message_provenance.py
+++ b/core/domain/message_provenance.py
@@ -8,6 +8,7 @@
class ClientSurface(StrEnum):
CLI = "cli"
DESKTOP = "desktop"
+ WEB = "web"
HEADLESS = "headless"
AUTOMATION = "automation"
APP_SERVER = "app_server"
@@ -29,8 +30,17 @@ class TurnInputDelivery(StrEnum):
NEXT_TURN = "next_turn"
+class InputDeliveryState(StrEnum):
+ """Confirmation that a durable Steer was accepted by its live mailbox."""
+
+ PENDING = "pending"
+ ACCEPTED = "accepted"
+ UNKNOWN = "unknown"
+
+
__all__ = [
"ClientSurface",
"TurnInputDelivery",
+ "InputDeliveryState",
"TurnInputSource",
]
diff --git a/core/events/session.py b/core/events/session.py
index 8cc8510b8..88e1b94ea 100644
--- a/core/events/session.py
+++ b/core/events/session.py
@@ -19,6 +19,7 @@
from __future__ import annotations
import asyncio
+from collections.abc import Awaitable, Callable
from contextvars import ContextVar
from functools import partial
from pathlib import Path
@@ -399,9 +400,11 @@ def __init__(
tool_filter: Any | None = None,
closure_callback: Any | None = None,
mcp_runtime: McpSessionRuntime | None = None,
+ provider_cleanup: Callable[[], Awaitable[None]] | None = None,
) -> None:
self._runner = AgentRunner(provider)
self._provider = provider
+ self._provider_cleanup = provider_cleanup
self._tools = tools
self._model = model
self._system_prompt = system_prompt
@@ -692,6 +695,9 @@ async def aclose(self) -> None:
if self._mcp_runtime is not None:
await self._mcp_runtime.aclose()
await self._tools.aclose()
+ if self._provider_cleanup is not None:
+ await self._provider_cleanup()
+ self._provider_cleanup = None
async def _cancel_turn_subagents(self) -> None:
"""Stop delegated work without letting repeated Stop interrupt teardown."""
diff --git a/core/observability/bus.py b/core/observability/bus.py
index 31d476018..baf95ee51 100644
--- a/core/observability/bus.py
+++ b/core/observability/bus.py
@@ -26,7 +26,7 @@
import threading
from datetime import datetime
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, TextIO
from loguru import logger as _loguru_logger
@@ -98,6 +98,7 @@ def setup_logging(
*,
workspace_root: Path | None = None,
force: bool = False,
+ console_sink: TextIO | logging.Handler | None = None,
) -> None:
"""Wire up loguru sinks. Idempotent unless ``force=True``.
@@ -108,6 +109,9 @@ def setup_logging(
``workspace_root`` controls where the global log file lives (``logs/``
is created relative to it). When omitted the current working
directory is used.
+
+ ``console_sink`` lets a headless host redirect the console channel without
+ replacing the shared stdlib/loguru bridge or duplicating its routing.
"""
global _INITIALISED, _LLM_PREVIEW_CHARS, _MCP_PREVIEW_CHARS
@@ -146,7 +150,7 @@ def setup_logging(
if "console" in transports or not transports:
sid = _loguru_logger.add(
- sys.stderr,
+ console_sink if console_sink is not None else sys.stderr,
level=level,
format=_console_format,
backtrace=False,
diff --git a/core/persistence/database.py b/core/persistence/database.py
index 0cc397cbd..2b1d12af2 100644
--- a/core/persistence/database.py
+++ b/core/persistence/database.py
@@ -15,6 +15,7 @@
from core.file_lock import exclusive_file_lock
from core.persistence.migrations import (
LATEST_SCHEMA_VERSION,
+ MigrationError,
current_version,
migrate,
)
@@ -37,10 +38,27 @@ def __init__(self, path: Path | str | None = None) -> None:
Path(path).expanduser().resolve() if path else default_database_path()
)
+ @property
+ def restore_marker(self) -> Path:
+ return self.path.with_name(self.path.name + ".restore.json")
+
+ @property
+ def restore_recovery_marker(self) -> Path:
+ return self.path.with_name(self.path.name + ".restored.json")
+
def initialize(self, *, target_version: int = LATEST_SCHEMA_VERSION) -> None:
ensure_private_directory(self.path.parent)
with exclusive_file_lock(self._migration_lock_path()):
+ if self.restore_marker.exists():
+ raise RuntimeError(
+ "A state restore is pending. Resume it with deepcode service restore before starting the application."
+ )
had_existing_database = self._has_existing_database()
+ installed = self.schema_version()
+ if installed > target_version:
+ raise MigrationError(
+ f"database schema {installed} is newer than supported {target_version}"
+ )
connection = self._connect()
try:
self._enable_wal(connection)
@@ -61,8 +79,11 @@ def schema_version(self) -> int:
if not self._has_existing_database():
return 0
- with self.read() as connection:
+ connection = sqlite3.connect(self.path.as_uri() + "?mode=ro", uri=True)
+ try:
return current_version(connection)
+ finally:
+ connection.close()
@staticmethod
def _enable_wal(connection: sqlite3.Connection) -> None:
diff --git a/core/persistence/event_repository.py b/core/persistence/event_repository.py
index 5b07e74af..a9ac09266 100644
--- a/core/persistence/event_repository.py
+++ b/core/persistence/event_repository.py
@@ -61,11 +61,19 @@ def replay(
*,
after: int = 0,
limit: int = 500,
+ through: int | None = None,
) -> list[DomainEvent]:
+ upper_bound = " AND sequence <= ?" if through is not None else ""
+ parameters = (
+ (thread_id, after, through, limit)
+ if through is not None
+ else (thread_id, after, limit)
+ )
rows = self.connection.execute(
- "SELECT * FROM event_log WHERE thread_id = ? AND sequence > ? "
+ "SELECT * FROM event_log WHERE thread_id = ? AND sequence > ?"
+ f"{upper_bound} "
"ORDER BY sequence LIMIT ?",
- (thread_id, after, limit),
+ parameters,
).fetchall()
return [self._from_row(row) for row in rows]
diff --git a/core/persistence/execution_repository.py b/core/persistence/execution_repository.py
index c18ef2949..eaa4223c2 100644
--- a/core/persistence/execution_repository.py
+++ b/core/persistence/execution_repository.py
@@ -226,10 +226,37 @@ def list_active(self) -> list[Turn]:
).fetchall()
return [self._from_row(row) for row in rows]
- def list_for_thread(self, thread_id: str) -> list[Turn]:
- rows = self.connection.execute(
- "SELECT * FROM turns WHERE thread_id = ? ORDER BY ordinal", (thread_id,)
- ).fetchall()
+ def list_for_thread(
+ self,
+ thread_id: str,
+ *,
+ limit: int | None = None,
+ offset: int = 0,
+ state: str = "all",
+ ) -> list[Turn]:
+ filters = {
+ "all": "",
+ "active": " AND status IN ('queued', 'running', 'waiting_approval')",
+ "executing": " AND status IN ('running', 'waiting_approval')",
+ }
+ if state not in filters or offset < 0 or (limit is not None and limit < 1):
+ raise ValueError("Invalid Turn page")
+ order = (
+ "CASE status WHEN 'running' THEN 0 WHEN 'waiting_approval' THEN 0 ELSE 1 END, ordinal"
+ if state == "active"
+ else "ordinal"
+ )
+ query = (
+ "SELECT * FROM turns WHERE thread_id = ?"
+ + filters[state]
+ + " ORDER BY "
+ + order
+ )
+ arguments = [thread_id]
+ if limit is not None:
+ query += " LIMIT ? OFFSET ?"
+ arguments.extend((limit, offset))
+ rows = self.connection.execute(query, arguments).fetchall()
return [self._from_row(row) for row in rows]
def list_for_goal(self, thread_id: str, goal_id: str) -> list[Turn]:
diff --git a/core/private_storage.py b/core/private_storage.py
index d67899529..2ab496b99 100644
--- a/core/private_storage.py
+++ b/core/private_storage.py
@@ -102,6 +102,10 @@ def _run_icacls(executable: str, path: Path, *arguments: str) -> bool:
errors="replace",
timeout=15,
check=True,
+ # A detached service has no console to inherit. Avoid allocating
+ # a new console for every ACL helper it launches.
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
+ stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.SubprocessError):
return False
@@ -320,3 +324,30 @@ def _chmod(path: Path, mode: int, *, force: bool = False) -> None:
"open_existing_private_file",
"open_private_file",
]
+
+
+def atomic_write_private_json(path: Path, value: object) -> None:
+ """Publish private JSON durably; callers own any cross-process mutation lock."""
+ import json
+ import uuid
+
+ ensure_private_directory(path.parent)
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ payload = (
+ json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
+ ).encode()
+ descriptor = open_private_file(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
+ try:
+ with os.fdopen(descriptor, "wb") as stream:
+ stream.write(payload)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path)
+ if os.name != "nt":
+ directory = os.open(path.parent, os.O_RDONLY)
+ try:
+ os.fsync(directory)
+ finally:
+ os.close(directory)
+ finally:
+ temporary.unlink(missing_ok=True)
diff --git a/core/providers/anthropic.py b/core/providers/anthropic.py
index 675d9c1eb..2108b2119 100644
--- a/core/providers/anthropic.py
+++ b/core/providers/anthropic.py
@@ -14,22 +14,25 @@
from core.observability import log_llm_call
from core.providers.base import (
LLMProvider,
+ ProviderCapabilityError,
+ ProviderConfigurationError,
LLMResponse,
ReasoningDeltaCallback,
ToolCallRequest,
)
+from core.providers.protocol_config import ProviderCompat
from core.providers.reasoning import (
ANTHROPIC_THINKING_BLOCKS,
infer_reasoning_capabilities,
normalize_reasoning_effort,
)
-from core.reasoning import ReasoningChannel
from core.providers.timeouts import (
StreamIdleTimeoutError,
iter_with_stream_idle_timeout,
resolve_stream_idle_timeout_s,
wait_for_stream_activity,
)
+from core.reasoning import ReasoningChannel
_ALNUM = string.ascii_letters + string.digits
@@ -87,16 +90,20 @@ def __init__(
api_base: str | None = None,
default_model: str = "claude-sonnet-4-20250514",
extra_headers: dict[str, str] | None = None,
+ compat: ProviderCompat | None = None,
):
super().__init__(api_key, api_base)
self.default_model = default_model
self.extra_headers = extra_headers or {}
+ self.compat = compat or ProviderCompat()
+ self.compat.validate_protocol("anthropic_messages")
from anthropic import AsyncAnthropic
client_kw: dict[str, Any] = {}
if api_key:
client_kw["api_key"] = api_key
+ client_kw["auth_token"] = ""
if api_base:
client_kw["base_url"] = api_base
if extra_headers:
@@ -104,9 +111,28 @@ def __init__(
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
client_kw["max_retries"] = 0
self._client = AsyncAnthropic(**client_kw)
+ if api_key:
+ self._client.auth_token = None
+
+ async def aclose(self) -> None:
+ await self._client.close()
+
+ def _set_runtime_credential(self, key: str | None) -> None:
+ self.api_key = key
+ self._client.api_key = key
+ self._client.auth_token = None
@classmethod
def _handle_error(cls, e: Exception) -> LLMResponse:
+ if isinstance(e, (ProviderCapabilityError, ProviderConfigurationError)):
+ return LLMResponse(
+ content=str(e),
+ finish_reason="error",
+ error_kind="capability"
+ if isinstance(e, ProviderCapabilityError)
+ else "configuration",
+ error_should_retry=False,
+ )
response = getattr(e, "response", None)
headers = getattr(response, "headers", None)
payload = (
@@ -460,6 +486,7 @@ def _build_kwargs(
tool_choice: str | dict[str, Any] | None,
supports_caching: bool = True,
) -> dict[str, Any]:
+ self.validate_request_capabilities(messages, tools)
model_name = self._strip_prefix(model or self.default_model)
system, anthropic_msgs = self._convert_messages(
self._sanitize_empty_content(messages)
@@ -475,7 +502,12 @@ def _build_kwargs(
max_tokens = max(1, max_tokens)
effort = normalize_reasoning_effort(reasoning_effort)
- summarized_thinking = self._uses_summarized_thinking(model_name, effort)
+ if self.reasoning_supported is False:
+ effort = None
+ summarized_thinking = (
+ self.reasoning_supported is not False
+ and self._uses_summarized_thinking(model_name, effort)
+ )
thinking_enabled = summarized_thinking or effort not in {None, "auto", "none"}
kwargs: dict[str, Any] = {
@@ -502,6 +534,15 @@ def _build_kwargs(
else:
kwargs["temperature"] = temperature
+ if self.compat.temperature is False:
+ kwargs.pop("temperature", None)
+ elif self.compat.temperature is True:
+ kwargs.setdefault("temperature", temperature)
+ if "temperature" in kwargs:
+ # SDK 1.x removed the typed parameter. The documented extra_body
+ # path preserves the same legacy wire value on both SDK generations.
+ kwargs["extra_body"] = {"temperature": kwargs.pop("temperature")}
+
if anthropic_tools:
kwargs["tools"] = anthropic_tools
tc = self._convert_tool_choice(tool_choice, thinking_enabled)
@@ -630,18 +671,20 @@ async def chat(
model_name = self._strip_prefix(model or self.default_model)
effort = normalize_reasoning_effort(reasoning_effort)
summarized_thinking = self._uses_summarized_thinking(model_name, effort)
- kwargs = self._build_kwargs(
- messages,
- tools,
- model,
- max_tokens,
- temperature,
- reasoning_effort,
- tool_choice,
- )
started = time.monotonic()
result: LLMResponse | None = None
try:
+ await self.refresh_request_credentials()
+ kwargs = self._build_kwargs(
+ messages,
+ tools,
+ model,
+ max_tokens,
+ temperature,
+ reasoning_effort,
+ tool_choice,
+ )
+
response = await self._client.messages.create(**kwargs)
result = self._parse_response(
response,
@@ -649,7 +692,9 @@ async def chat(
)
return result
except Exception as e:
- result = self._handle_error(e)
+ result = self.redact_error(
+ self._handle_error(e), e, [self.api_key, *self.extra_headers.values()]
+ )
return result
finally:
self._emit_observability(
@@ -675,19 +720,21 @@ async def chat_stream(
model_name = self._strip_prefix(model or self.default_model)
effort = normalize_reasoning_effort(reasoning_effort)
summarized_thinking = self._uses_summarized_thinking(model_name, effort)
- kwargs = self._build_kwargs(
- messages,
- tools,
- model,
- max_tokens,
- temperature,
- reasoning_effort,
- tool_choice,
- )
idle_timeout_s = resolve_stream_idle_timeout_s()
started = time.monotonic()
result: LLMResponse | None = None
try:
+ await self.refresh_request_credentials()
+ kwargs = self._build_kwargs(
+ messages,
+ tools,
+ model,
+ max_tokens,
+ temperature,
+ reasoning_effort,
+ tool_choice,
+ )
+
async with self._client.messages.stream(**kwargs) as stream:
async for event in iter_with_stream_idle_timeout(
stream, timeout_s=idle_timeout_s
@@ -724,7 +771,9 @@ async def chat_stream(
)
return result
except Exception as e:
- result = self._handle_error(e)
+ result = self.redact_error(
+ self._handle_error(e), e, [self.api_key, *self.extra_headers.values()]
+ )
return result
finally:
self._emit_observability(
diff --git a/core/providers/base.py b/core/providers/base.py
index 79f2b6ff9..2d1f35bdb 100644
--- a/core/providers/base.py
+++ b/core/providers/base.py
@@ -14,7 +14,6 @@
from core.reasoning import ReasoningChannel
-
ReasoningDeltaCallback = Callable[[str, ReasoningChannel], Awaitable[None]]
_CONTEXT_WINDOW_MARKERS = (
@@ -94,6 +93,14 @@ def to_openai_tool_call(self) -> dict[str, Any]:
return tool_call
+class ProviderConfigurationError(ValueError):
+ """A frozen route or live credential is no longer valid for this Turn."""
+
+
+class ProviderCapabilityError(ValueError):
+ """The user explicitly declared this request capability unsupported."""
+
+
@dataclass
class LLMResponse:
"""Response from an LLM provider."""
@@ -117,6 +124,7 @@ class LLMResponse:
error_code: str | None = None
error_retry_after_s: float | None = None
error_should_retry: bool | None = None
+ partial_output: bool = False
@property
def has_tool_calls(self) -> bool:
@@ -223,6 +231,72 @@ class LLMProvider(ABC):
_SENTINEL = object()
+ request_guard = None
+
+ async def refresh_request_credentials(self) -> None:
+ if self.request_guard is not None:
+ import asyncio
+
+ try:
+ key = await asyncio.to_thread(self.request_guard)
+ except ValueError as exc:
+ raise ProviderConfigurationError(str(exc)) from exc
+ self._set_runtime_credential(key)
+
+ async def aclose(self) -> None:
+ """Release transport resources owned by this provider instance."""
+
+ def _set_runtime_credential(self, key: str | None) -> None:
+ self.api_key = key
+
+ @staticmethod
+ def redact_error(response: LLMResponse, error: Exception, values) -> LLMResponse:
+ """Keep echoed request credentials out of owned errors and observability."""
+ secrets = {value for value in values if isinstance(value, str) and value}
+ try:
+ request = getattr(error, "request", None)
+ except RuntimeError:
+ request = None
+ headers = getattr(request, "headers", {})
+ for name in ("authorization", "proxy-authorization", "x-api-key", "api-key"):
+ value = headers.get(name)
+ if isinstance(value, str) and value:
+ secrets.add(value)
+ if name.endswith("authorization") and " " in value:
+ token = value.split(" ", 1)[1]
+ if token:
+ secrets.add(token)
+ for attribute in ("content", "error_type", "error_code"):
+ text = getattr(response, attribute)
+ if isinstance(text, str):
+ for secret in sorted(secrets, key=len, reverse=True):
+ text = text.replace(secret, "[redacted]")
+ setattr(response, attribute, text)
+ return response
+
+ input_modalities: tuple[str, ...] | None = None
+ tool_calling: bool | None = None
+ reasoning_supported: bool | None = None
+
+ def validate_request_capabilities(
+ self, messages: list[dict], tools: list[dict] | None
+ ) -> None:
+ if tools and self.tool_calling is False:
+ raise ProviderCapabilityError(
+ "The selected model is declared not to support tool calling"
+ )
+ if self.input_modalities is not None and "image" not in self.input_modalities:
+ for message in messages:
+ content = message.get("content")
+ if isinstance(content, list) and any(
+ isinstance(block, dict)
+ and block.get("type") in {"image", "image_url", "input_image"}
+ for block in content
+ ):
+ raise ProviderCapabilityError(
+ "The selected model is declared not to support image input"
+ )
+
def __init__(self, api_key: str | None = None, api_base: str | None = None):
self.api_key = api_key
self.api_base = api_base
@@ -562,19 +636,45 @@ async def chat_stream(
response.reasoning_content,
ReasoningChannel.PROVIDER_TRACE,
)
- if on_content_delta and response.content:
+ if on_content_delta and response.content and response.finish_reason != "error":
await on_content_delta(response.content)
return response
async def _safe_chat_stream(self, **kwargs: Any) -> LLMResponse:
+ delivered = False
+ content_callback = kwargs.get("on_content_delta")
+ reasoning_callback = kwargs.get("on_reasoning_delta")
+
+ async def content_delta(text):
+ nonlocal delivered
+ delivered = delivered or bool(text)
+ if content_callback:
+ await content_callback(text)
+
+ async def reasoning_delta(text, channel):
+ nonlocal delivered
+ delivered = delivered or bool(text)
+ if reasoning_callback:
+ await reasoning_callback(text, channel)
+
try:
- return await self.chat_stream(**kwargs)
+ response = await self.chat_stream(
+ **{
+ **kwargs,
+ "on_content_delta": content_delta,
+ "on_reasoning_delta": reasoning_delta,
+ }
+ )
except asyncio.CancelledError:
raise
except Exception as exc:
- return LLMResponse(
+ response = LLMResponse(
content=f"Error calling LLM: {exc}", finish_reason="error"
)
+ if response.finish_reason == "error" and delivered:
+ response.partial_output = True
+ response.error_should_retry = False
+ return response
async def chat_stream_with_retry(
self,
@@ -770,7 +870,11 @@ async def _run_with_retry(
while True:
attempt += 1
response = await call(**kw)
- if response.finish_reason != "error":
+ if (
+ response.finish_reason != "error"
+ or response.partial_output
+ or response.error_kind in {"capability", "configuration"}
+ ):
return response
last_response = response
error_key = (response.content or "").strip().lower() or None
diff --git a/core/providers/catalog_service.py b/core/providers/catalog_service.py
index e03c30351..e0828bee5 100644
--- a/core/providers/catalog_service.py
+++ b/core/providers/catalog_service.py
@@ -21,6 +21,7 @@
from core.providers.reasoning import (
ModelReasoningCapabilities,
infer_reasoning_capabilities,
+ declared_reasoning_capabilities,
)
@@ -32,6 +33,8 @@ class CatalogModel:
max_output_tokens: int
supported_parameters: tuple[str, ...] = ()
reasoning: ModelReasoningCapabilities | None = None
+ input_modalities: tuple[str, ...] | None = None
+ tool_calling: bool | None = None
def to_dict(self) -> dict[str, Any]:
return {
@@ -41,6 +44,16 @@ def to_dict(self) -> dict[str, Any]:
"maxOutputTokens": self.max_output_tokens,
"supportedParameters": list(self.supported_parameters),
"reasoning": self.reasoning.to_dict() if self.reasoning else None,
+ **(
+ {"inputModalities": list(self.input_modalities)}
+ if self.input_modalities is not None
+ else {}
+ ),
+ **(
+ {"toolCalling": self.tool_calling}
+ if self.tool_calling is not None
+ else {}
+ ),
}
@classmethod
@@ -64,6 +77,10 @@ def from_dict(cls, value: Any) -> CatalogModel | None:
for item in value.get("supportedParameters", [])
if isinstance(item, str)
),
+ input_modalities=tuple(value["inputModalities"])
+ if value.get("inputModalities") is not None
+ else None,
+ tool_calling=value.get("toolCalling"),
reasoning=(
ModelReasoningCapabilities.from_dict(value.get("reasoning"))
or infer_reasoning_capabilities(
@@ -472,6 +489,9 @@ def _has_declarations(entry: ManualModelConfig) -> bool:
or entry.context_window is not None
or entry.max_output_tokens is not None
or entry.reasoning_efforts is not None
+ or entry.input_modalities is not None
+ or entry.tool_calling is not None
+ or bool(entry.compat.model_dump(exclude_none=True))
)
@@ -484,31 +504,22 @@ def _declared_model(
anything it leaves unsaid falls through — to the discovered row when
overlaying a remote catalog, otherwise to the built-in cascade."""
base = base if base is not None else _offline_model(entry.id)
- if entry.reasoning_efforts is None:
- reasoning = base.reasoning
- elif entry.reasoning_efforts is False:
- # Declared non-reasoning: block the inference fallback with an
- # explicit "no controls" answer instead of an absent one.
- reasoning = ModelReasoningCapabilities()
- else:
- levels = tuple(
- dict.fromkeys(
- level.strip().lower()
- for level in entry.reasoning_efforts
- if level.strip()
- )
- )
- reasoning = ModelReasoningCapabilities(
- supported_efforts=tuple(level for level in levels if level != "off"),
- default_enabled=True,
- mandatory="off" not in levels,
- )
+ reasoning = declared_reasoning_capabilities(
+ entry.reasoning_efforts, base.reasoning or ModelReasoningCapabilities()
+ )
return CatalogModel(
id=entry.id,
name=entry.label or base.name,
context_window=entry.context_window or base.context_window,
max_output_tokens=entry.max_output_tokens or base.max_output_tokens,
reasoning=reasoning,
+ supported_parameters=base.supported_parameters,
+ input_modalities=tuple(entry.input_modalities)
+ if entry.input_modalities is not None
+ else base.input_modalities,
+ tool_calling=entry.tool_calling
+ if entry.tool_calling is not None
+ else base.tool_calling,
)
diff --git a/core/providers/credentials.py b/core/providers/credentials.py
index 7ddd25bf9..b4e406ab2 100644
--- a/core/providers/credentials.py
+++ b/core/providers/credentials.py
@@ -5,17 +5,16 @@
import hashlib
import json
import os
+import secrets
import threading
-import uuid
from pathlib import Path
from typing import Any
from core.config import deepcode_home
from core.file_lock import exclusive_file_lock
from core.private_storage import (
- ensure_private_directory,
+ atomic_write_private_json,
open_existing_private_file,
- open_private_file,
)
@@ -49,16 +48,17 @@ def set(self, connection_id: str, api_key: str) -> None:
clean = api_key.strip()
if not clean:
raise ValueError("api key must not be empty")
- self._mutate(
- lambda data: {
+
+ def transform(data):
+ self._invalidate_login(data, connection_id)
+ data.setdefault("accounts", {}).pop(connection_id, None)
+ return {
**data,
"version": 1,
- "connections": {
- **_connections(data),
- connection_id: clean,
- },
+ "connections": {**_connections(data), connection_id: clean},
}
- )
+
+ self._mutate(transform)
def clear(self, connection_id: str) -> bool:
removed = False
@@ -66,12 +66,78 @@ def clear(self, connection_id: str) -> bool:
def transform(data: dict[str, Any]) -> dict[str, Any]:
nonlocal removed
connections = _connections(data)
+ self._invalidate_login(data, connection_id)
+ data.setdefault("accounts", {}).pop(connection_id, None)
removed = connections.pop(connection_id, None) is not None
return {**data, "version": 1, "connections": connections}
self._mutate(transform)
return removed
+ def oauth_credential(self, connection_id: str) -> tuple[str | None, str | None]:
+ data = self._read()
+ account = data.get("accounts", {}).get(connection_id, {})
+ key = data.get("connections", {}).get(connection_id)
+ if (
+ not isinstance(account, dict)
+ or account.get("provider") != "openrouter"
+ or not isinstance(account.get("accountId"), str)
+ or not account["accountId"]
+ or not isinstance(key, str)
+ or not key
+ ):
+ return None, None
+ return key, account.get("accountId")
+
+ @staticmethod
+ def _invalidate_login(data, connection_id):
+ generation = secrets.token_hex(24)
+ data.setdefault("loginGenerations", {})[connection_id] = generation
+ return generation
+
+ def begin_login(self, connection_id: str) -> str:
+ generation = None
+
+ def transform(data):
+ nonlocal generation
+ generation = self._invalidate_login(data, connection_id)
+ return data
+
+ self._mutate(transform)
+ return generation
+
+ def cancel_login(self, connection_id: str, generation: str) -> None:
+ def transform(data):
+ if data.get("loginGenerations", {}).get(connection_id) == generation:
+ self._invalidate_login(data, connection_id)
+ return data
+
+ self._mutate(transform)
+
+ def complete_login(
+ self, connection_id: str, generation: str, *, api_key: str, account_id: str
+ ) -> None:
+ if not api_key or not account_id or len(account_id) > 256:
+ raise ValueError("The provider returned an invalid account identity")
+
+ def transform(data):
+ if data.get("loginGenerations", {}).get(connection_id) != generation:
+ raise ValueError("This login was cancelled or superseded")
+ existing = data.get("accounts", {}).get(connection_id)
+ if existing and existing.get("accountId") != account_id:
+ raise ValueError(
+ "A different account was selected. Disconnect the existing account before switching."
+ )
+ data.setdefault("connections", {})[connection_id] = api_key
+ data.setdefault("accounts", {})[connection_id] = {
+ "provider": "openrouter",
+ "accountId": account_id,
+ }
+ self._invalidate_login(data, connection_id)
+ return data
+
+ self._mutate(transform)
+
def revision(self) -> str:
"""Return a non-secret fingerprint suitable for runtime invalidation."""
@@ -102,6 +168,9 @@ def _read(self) -> dict[str, Any]:
raise ValueError("unsupported DeepCode credentials version")
if not isinstance(value.get("connections", {}), dict):
raise TypeError("credentials.connections must be an object")
+ for field in ("accounts", "loginGenerations"):
+ if not isinstance(value.get(field, {}), dict):
+ raise TypeError(f"credentials.{field} must be an object")
return value
def _mutate(self, transform) -> None:
@@ -110,25 +179,7 @@ def _mutate(self, transform) -> None:
self._replace(updated)
def _replace(self, value: dict[str, Any]) -> None:
- ensure_private_directory(self.path.parent)
- temporary = self.path.with_name(f".{self.path.name}.{uuid.uuid4().hex}.tmp")
- descriptor = open_private_file(
- temporary,
- os.O_WRONLY | os.O_CREAT | os.O_EXCL,
- )
- try:
- payload = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
- with os.fdopen(descriptor, "wb") as handle:
- handle.write(payload)
- handle.flush()
- os.fsync(handle.fileno())
- os.replace(temporary, self.path)
- _fsync_directory(self.path.parent)
- finally:
- try:
- temporary.unlink()
- except FileNotFoundError:
- pass
+ atomic_write_private_json(self.path, value)
def _connections(data: dict[str, Any]) -> dict[str, str]:
@@ -136,14 +187,4 @@ def _connections(data: dict[str, Any]) -> dict[str, str]:
return dict(value) if isinstance(value, dict) else {}
-def _fsync_directory(path: Path) -> None:
- if os.name == "nt":
- return
- descriptor = os.open(path, os.O_RDONLY)
- try:
- os.fsync(descriptor)
- finally:
- os.close(descriptor)
-
-
__all__ = ["CredentialStore", "default_credentials_path"]
diff --git a/core/providers/oauth.py b/core/providers/oauth.py
new file mode 100644
index 000000000..42708c56e
--- /dev/null
+++ b/core/providers/oauth.py
@@ -0,0 +1,297 @@
+"""OpenRouter's PKCE-to-API-key login, with user-private account binding.
+
+OpenRouter issues a user-controlled key, not a refresh token. This adapter
+therefore never invents refresh or remote-revocation operations.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import secrets
+import threading
+import time
+import webbrowser
+from dataclasses import dataclass, field
+from urllib.parse import urlencode
+
+import httpx
+from aiohttp import web
+
+from core.providers.credentials import CredentialStore
+
+FLOW_TTL = 300
+
+
+@dataclass(slots=True)
+class _Flow:
+ id: str
+ connection_id: str
+ generation: str
+ expires_at: float
+ status: str = "starting"
+ url: str | None = None
+ error: str | None = None
+ account_id: str | None = None
+ ready: threading.Event = field(default_factory=threading.Event)
+ cancel: threading.Event = field(default_factory=threading.Event)
+ thread: threading.Thread | None = None
+
+
+class ProviderOAuthManager:
+ def __init__(self, credentials: CredentialStore):
+ self.credentials = credentials
+ self._flows: dict[str, _Flow] = {}
+ self._lock = threading.RLock()
+ self._closed = False
+
+ def start(self, connection_id: str, *, open_browser: bool = False) -> dict:
+ with self._lock:
+ if self._closed:
+ raise ValueError("Provider login service is closed")
+ self._flows = {
+ key: flow
+ for key, flow in self._flows.items()
+ if flow.expires_at + 300 > time.monotonic()
+ }
+ active = [
+ flow
+ for flow in self._flows.values()
+ if flow.status in {"starting", "pending", "exchanging"}
+ ]
+ if len(active) >= 8:
+ raise ValueError(
+ "Too many pending provider logins; cancel a login first"
+ )
+ for flow in active:
+ if flow.connection_id == connection_id:
+ self.cancel(flow.id)
+ flow = _Flow(
+ secrets.token_urlsafe(24),
+ connection_id,
+ self.credentials.begin_login(connection_id),
+ time.monotonic() + FLOW_TTL,
+ )
+ self._flows[flow.id] = flow
+ # Keep a bounded terminal history without dropping active sockets.
+ terminal = [
+ key
+ for key, item in self._flows.items()
+ if item.status not in {"starting", "pending", "exchanging"}
+ ]
+ for key in terminal[:-24]:
+ self._flows.pop(key)
+ flow.thread = threading.Thread(
+ target=self._run,
+ args=(flow,),
+ name="deepcode-provider-login",
+ daemon=True,
+ )
+ flow.thread.start()
+ if not flow.ready.wait(5):
+ self.cancel(flow.id)
+ raise ValueError("The local provider login callback did not start")
+ result = self.poll(flow.id)
+ if open_browser and result["authorizationUrl"]:
+ webbrowser.open(result["authorizationUrl"])
+ return result
+
+ def poll(self, flow_id: str) -> dict:
+ with self._lock:
+ flow = self._flows.get(flow_id)
+ if flow is None:
+ raise ValueError("Unknown or expired provider login")
+ return {
+ "flowId": flow.id,
+ "connectionId": flow.connection_id,
+ "provider": "openrouter",
+ "status": flow.status,
+ "authorizationUrl": flow.url if flow.status == "pending" else None,
+ "expiresInSeconds": max(0, int(flow.expires_at - time.monotonic())),
+ "error": flow.error,
+ "accountId": flow.account_id,
+ "refreshSupported": False,
+ }
+
+ def cancel(self, flow_id: str) -> dict:
+ with self._lock:
+ flow = self._flows.get(flow_id)
+ if flow is None:
+ raise ValueError("Unknown or expired provider login")
+ if flow.status in {"starting", "pending", "exchanging"}:
+ self.credentials.cancel_login(flow.connection_id, flow.generation)
+ flow.cancel.set()
+ flow.status = "cancelled"
+ flow.url = None
+ return self.poll(flow_id)
+
+ def close(self):
+ with self._lock:
+ self._closed = True
+ flows = list(self._flows.values())
+ for flow in flows:
+ self.cancel(flow.id)
+ deadline = time.monotonic() + 3
+ for flow in flows:
+ if flow.thread is not None:
+ flow.thread.join(max(0, deadline - time.monotonic()))
+
+ def _run(self, flow):
+ try:
+ asyncio.run(self._authorize(flow))
+ except (
+ Exception
+ ): # callback startup/transport errors must not expose codes or keys
+ with self._lock:
+ if flow.status != "cancelled":
+ flow.status = "failed"
+ flow.error = "Provider authorization could not be completed"
+ finally:
+ flow.url = None
+ flow.ready.set()
+
+ async def _authorize(self, flow):
+ verifier = secrets.token_urlsafe(48)
+ challenge = (
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
+ .decode()
+ .rstrip("=")
+ )
+ state = secrets.token_urlsafe(32)
+ callback_path = "/auth/provider/" + state
+ done = asyncio.Event()
+ exchange = None
+ allowed_hosts = set()
+
+ async def callback(request):
+ nonlocal exchange
+ if request.host not in allowed_hosts or request.remote != "127.0.0.1":
+ raise web.HTTPForbidden()
+ with self._lock:
+ if flow.status != "pending" or flow.cancel.is_set():
+ raise web.HTTPConflict(text="This login is no longer pending")
+ codes = request.query.getall("code", [])
+ if "error" in request.query:
+ flow.status = "cancelled"
+ self.credentials.cancel_login(flow.connection_id, flow.generation)
+ done.set()
+ return web.Response(text="Authorization cancelled")
+ if len(codes) != 1 or not 1 <= len(codes[0]) <= 4096:
+ raise web.HTTPBadRequest(text="Missing authorization code")
+ flow.status = "exchanging"
+ exchange = asyncio.current_task()
+ try:
+ async with httpx.AsyncClient(
+ timeout=20, follow_redirects=False
+ ) as client:
+ response = await client.post(
+ "https://openrouter.ai/api/v1/auth/keys",
+ json={
+ "code": codes[0],
+ "code_verifier": verifier,
+ "code_challenge_method": "S256",
+ },
+ )
+ response.raise_for_status()
+ if len(response.content) > 65536:
+ raise ValueError("Invalid authorization response")
+ value = response.json()
+ key = value.get("key")
+ account = value.get("user_id")
+ if (
+ not isinstance(key, str)
+ or not key
+ or not isinstance(account, str)
+ or not account
+ ):
+ raise ValueError(
+ "The provider returned no verifiable account identity"
+ )
+ with self._lock:
+ if flow.cancel.is_set():
+ raise ValueError("This login was cancelled")
+ self.credentials.complete_login(
+ flow.connection_id,
+ flow.generation,
+ api_key=key,
+ account_id=account,
+ )
+ flow.account_id = account
+ flow.status = "authenticated"
+ except ValueError as exc:
+ with self._lock:
+ if flow.status != "cancelled":
+ flow.status = "failed"
+ # Only our credential-store errors are projected; never an HTTP body.
+ flow.error = (
+ str(exc)
+ if str(exc)
+ in {
+ "This login was cancelled or superseded",
+ "A different account was selected. Disconnect the existing account before switching.",
+ "The provider returned no verifiable account identity",
+ }
+ else "The provider returned an invalid authorization response"
+ )
+ except Exception:
+ with self._lock:
+ if flow.status != "cancelled":
+ flow.status = "failed"
+ flow.error = (
+ "The provider rejected or could not complete authorization"
+ )
+ finally:
+ done.set()
+ nonce = secrets.token_urlsafe(18)
+ return web.Response(
+ content_type="text/html",
+ text=f'DeepCode Authorization processed. Return to DeepCode to check its status.
',
+ headers={
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "no-referrer",
+ "Content-Security-Policy": f"default-src 'none'; script-src 'nonce-{nonce}'; frame-ancestors 'none'",
+ },
+ )
+
+ app = web.Application(client_max_size=8192)
+ app.router.add_get(callback_path, callback)
+ runner = web.AppRunner(app, access_log=None, shutdown_timeout=1)
+ await runner.setup()
+ try:
+ site = web.TCPSite(runner, "127.0.0.1", 0)
+ await site.start()
+ port = runner.addresses[0][1]
+ allowed_hosts.update({f"127.0.0.1:{port}", f"localhost:{port}"})
+ url = "https://openrouter.ai/auth?" + urlencode(
+ {
+ "callback_url": f"http://localhost:{port}{callback_path}",
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ }
+ )
+ with self._lock:
+ if not flow.cancel.is_set():
+ flow.url = url
+ flow.status = "pending"
+ flow.ready.set()
+ while (
+ not done.is_set()
+ and not flow.cancel.is_set()
+ and time.monotonic() < flow.expires_at
+ ):
+ await asyncio.sleep(0.1)
+ with self._lock:
+ if flow.status in {"pending", "exchanging"}:
+ flow.status = "expired" if not flow.cancel.is_set() else "cancelled"
+ self.credentials.cancel_login(flow.connection_id, flow.generation)
+ if (
+ exchange is not None
+ and not exchange.done()
+ and (flow.cancel.is_set() or flow.status == "expired")
+ ):
+ exchange.cancel()
+ if done.is_set():
+ await asyncio.sleep(0.1) # allow the bounded callback response to flush
+ finally:
+ await runner.cleanup()
diff --git a/core/providers/openai_compat.py b/core/providers/openai_compat.py
index f63010868..330ca1384 100644
--- a/core/providers/openai_compat.py
+++ b/core/providers/openai_compat.py
@@ -2,9 +2,9 @@
from __future__ import annotations
-import json
import hashlib
import importlib.util
+import json
import os
import secrets
import string
@@ -15,6 +15,7 @@
import json_repair
from loguru import logger
+from openai import Omit
from core.observability import log_llm_call
@@ -32,6 +33,8 @@
from core.providers.base import (
LLMProvider,
+ ProviderCapabilityError,
+ ProviderConfigurationError,
LLMResponse,
ReasoningDeltaCallback,
ToolCallRequest,
@@ -43,13 +46,17 @@
convert_tools,
parse_response_output,
)
-from core.providers.reasoning import OPENROUTER_REASONING_DETAILS
-from core.reasoning import ReasoningChannel
+from core.providers.protocol_config import ProviderCompat, apply_chat_compat
+from core.providers.reasoning import (
+ OPENROUTER_REASONING_DETAILS,
+ infer_reasoning_capabilities,
+)
from core.providers.timeouts import (
StreamIdleTimeoutError,
iter_with_stream_idle_timeout,
resolve_stream_idle_timeout_s,
)
+from core.reasoning import ReasoningChannel
if TYPE_CHECKING:
from core.providers.registry import ProviderSpec
@@ -231,11 +238,20 @@ def __init__(
default_model: str = "gpt-4o",
extra_headers: dict[str, str] | None = None,
spec: ProviderSpec | None = None,
+ protocol: str = "auto",
+ compat: ProviderCompat | None = None,
+ auth_mode: str = "api_key",
):
super().__init__(api_key, api_base)
self.default_model = default_model
self.extra_headers = extra_headers or {}
self._spec = spec
+ if protocol not in {"auto", "openai_chat", "openai_responses"}:
+ raise ValueError("Unsupported OpenAI-compatible protocol")
+ self.protocol = protocol
+ self.auth_mode = auth_mode
+ self.compat = compat or ProviderCompat()
+ self.compat.validate_protocol(protocol)
# The credential travels only on this instance and its client. It is
# deliberately never exported to os.environ: in a long-lived App
@@ -252,9 +268,17 @@ def __init__(
default_headers.update(_DEFAULT_REQUESTY_HEADERS)
if extra_headers:
default_headers.update(extra_headers)
+ if auth_mode == "none":
+ # Omit is the SDK's supported way to remove a default header.
+ # An empty key is rejected by some SDK versions at construction.
+ default_headers = {
+ key: value
+ for key, value in default_headers.items()
+ if key.lower() != "authorization"
+ }
self._client = AsyncOpenAI(
- api_key=api_key or "no-key",
+ api_key="no-key" if auth_mode == "none" else api_key or "no-key",
base_url=effective_base,
default_headers=default_headers,
max_retries=0,
@@ -266,6 +290,16 @@ def __init__(
self._responses_failures: dict[str, int] = {}
self._responses_tripped_at: dict[str, float] = {}
+ async def aclose(self) -> None:
+ await self._client.close()
+
+ def _set_runtime_credential(self, key: str | None) -> None:
+ self.api_key = key
+ self._client.api_key = "no-key" if self.auth_mode == "none" else key or "no-key"
+
+ def _request_headers(self) -> dict:
+ return {"Authorization": Omit()} if self.auth_mode == "none" else {}
+
@classmethod
def _apply_cache_control(
cls,
@@ -427,6 +461,7 @@ def _build_kwargs(
:func:`resolve_model_compat`; this method only assembles the payload
from that value — no ``model_name.lower()`` branching inline.
"""
+ self.validate_request_capabilities(messages, tools)
model_name = model or self.default_model
spec = self._spec
@@ -482,14 +517,26 @@ def _build_kwargs(
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
msg["reasoning_content"] = ""
- return kwargs
+ return apply_chat_compat(
+ kwargs,
+ self.compat.model_copy(update={"reasoning_field": "omit"})
+ if self.reasoning_supported is False
+ else self.compat,
+ max_tokens=max_tokens,
+ temperature=temperature,
+ reasoning_effort=reasoning_effort,
+ )
def _should_use_responses_api(
self,
model: str | None,
reasoning_effort: str | None,
) -> bool:
- """Use Responses API only for direct OpenAI requests that benefit from it."""
+ """Explicit choices are authoritative; auto retains the legacy heuristic."""
+ if self.protocol == "openai_responses":
+ return True
+ if self.protocol == "openai_chat":
+ return False
if self._spec and self._spec.name != "openai":
return False
if not _is_direct_openai_base(self._effective_base):
@@ -573,7 +620,8 @@ def _build_responses_body(
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
) -> dict[str, Any]:
- """Build a Responses API body for direct OpenAI requests."""
+ """Build a Responses API body for the explicitly selected or legacy route."""
+ self.validate_request_capabilities(messages, tools)
model_name = model or self.default_model
sanitized_messages = self._sanitize_messages(
self._sanitize_empty_content(messages), preserve_provider_state=True
@@ -595,10 +643,22 @@ def _build_responses_body(
compat = resolve_model_compat(
model_name=model_name, spec=self._spec, reasoning_effort=reasoning_effort
)
- if compat.include_temperature:
+ if self.compat.temperature is True or (
+ self.compat.temperature is None and compat.include_temperature
+ ):
body["temperature"] = temperature
- if self._should_use_responses_api(model, reasoning_effort):
+ wants_reasoning = (
+ self.protocol == "auto"
+ or self.compat.reasoning_field == "reasoning"
+ or reasoning_effort not in {None, "auto", "none"}
+ or infer_reasoning_capabilities(model_name) is not None
+ )
+ if (
+ wants_reasoning
+ and self.compat.reasoning_field != "omit"
+ and self.reasoning_supported is not False
+ ):
body["reasoning"] = {"summary": "auto"}
if reasoning_effort:
body["reasoning"]["effort"] = reasoning_effort.lower()
@@ -607,6 +667,8 @@ def _build_responses_body(
if tools:
body["tools"] = convert_tools(tools)
body["tool_choice"] = tool_choice or "auto"
+ if self.compat.parallel_tool_calls is not None:
+ body["parallel_tool_calls"] = self.compat.parallel_tool_calls
return body
@@ -1105,6 +1167,15 @@ def _handle_error(
spec: ProviderSpec | None = None,
api_base: str | None = None,
) -> LLMResponse:
+ if isinstance(e, (ProviderCapabilityError, ProviderConfigurationError)):
+ return LLMResponse(
+ content=str(e),
+ finish_reason="error",
+ error_kind="capability"
+ if isinstance(e, ProviderCapabilityError)
+ else "configuration",
+ error_should_retry=False,
+ )
body = (
getattr(e, "doc", None)
or getattr(e, "body", None)
@@ -1161,6 +1232,7 @@ async def chat(
started = time.monotonic()
response: LLMResponse | None = None
try:
+ await self.refresh_request_credentials()
if self._should_use_responses_api(model, reasoning_effort):
try:
body = self._build_responses_body(
@@ -1173,13 +1245,20 @@ async def chat(
tool_choice,
)
result = parse_response_output(
- await self._client.responses.create(**body)
+ await self._client.responses.create(
+ **body, extra_headers=self._request_headers()
+ )
)
self._record_responses_success(model, reasoning_effort)
response = result
return result
except Exception as responses_error:
- if not self._should_fallback_from_responses_error(responses_error):
+ if (
+ self.protocol == "openai_responses"
+ or not self._should_fallback_from_responses_error(
+ responses_error
+ )
+ ):
raise
self._record_responses_failure(model, reasoning_effort)
@@ -1192,10 +1271,18 @@ async def chat(
reasoning_effort,
tool_choice,
)
- response = self._parse(await self._client.chat.completions.create(**kwargs))
+ response = self._parse(
+ await self._client.chat.completions.create(
+ **kwargs, extra_headers=self._request_headers()
+ )
+ )
return response
except Exception as e:
- response = self._handle_error(e, spec=self._spec, api_base=self.api_base)
+ response = self.redact_error(
+ self._handle_error(e, spec=self._spec, api_base=self.api_base),
+ e,
+ [self.api_key, *self.extra_headers.values()],
+ )
return response
finally:
self._emit_observability(
@@ -1221,7 +1308,9 @@ async def chat_stream(
idle_timeout_s = resolve_stream_idle_timeout_s()
started = time.monotonic()
response: LLMResponse | None = None
+ stream = None
try:
+ await self.refresh_request_credentials()
if self._should_use_responses_api(model, reasoning_effort):
try:
body = self._build_responses_body(
@@ -1234,7 +1323,9 @@ async def chat_stream(
tool_choice,
)
body["stream"] = True
- stream = await self._client.responses.create(**body)
+ stream = await self._client.responses.create(
+ **body, extra_headers=self._request_headers()
+ )
async def _timed_stream():
async for event in iter_with_stream_idle_timeout(
@@ -1265,7 +1356,13 @@ async def _timed_stream():
)
return response
except Exception as responses_error:
- if not self._should_fallback_from_responses_error(responses_error):
+ if (
+ stream is not None
+ or self.protocol == "openai_responses"
+ or not self._should_fallback_from_responses_error(
+ responses_error
+ )
+ ):
raise
self._record_responses_failure(model, reasoning_effort)
@@ -1280,7 +1377,9 @@ async def _timed_stream():
)
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True}
- stream = await self._client.chat.completions.create(**kwargs)
+ stream = await self._client.chat.completions.create(
+ **kwargs, extra_headers=self._request_headers()
+ )
chunks: list[Any] = []
async for chunk in iter_with_stream_idle_timeout(
stream, timeout_s=idle_timeout_s
@@ -1295,6 +1394,12 @@ async def _timed_stream():
chunk.choices[0].delta,
on_reasoning_delta,
)
+ if not any(
+ choice.finish_reason is not None
+ for chunk in chunks
+ for choice in chunk.choices
+ ):
+ raise RuntimeError("Chat stream ended before its terminal event")
response = self._parse_chunks(chunks)
return response
except StreamIdleTimeoutError:
@@ -1308,9 +1413,15 @@ async def _timed_stream():
)
return response
except Exception as e:
- response = self._handle_error(e, spec=self._spec, api_base=self.api_base)
+ response = self.redact_error(
+ self._handle_error(e, spec=self._spec, api_base=self.api_base),
+ e,
+ [self.api_key, *self.extra_headers.values()],
+ )
return response
finally:
+ if stream is not None:
+ await stream.close()
self._emit_observability(
model=model,
messages=messages,
diff --git a/core/providers/openai_responses/parsing.py b/core/providers/openai_responses/parsing.py
index 823e5c634..57d915e4b 100644
--- a/core/providers/openai_responses/parsing.py
+++ b/core/providers/openai_responses/parsing.py
@@ -250,6 +250,7 @@ async def consume_sdk_stream(
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
finish_reason = "stop"
+ terminal_seen = False
usage: dict[str, int] = {}
reasoning_content: str | None = None
reasoning_items: list[dict[str, Any]] = []
@@ -316,7 +317,8 @@ async def consume_sdk_stream(
arguments=args,
)
)
- elif event_type == "response.completed":
+ elif event_type in {"response.completed", "response.incomplete"}:
+ terminal_seen = True
resp = getattr(event, "response", None)
status = getattr(resp, "status", None) if resp else None
finish_reason = map_finish_reason(status)
@@ -351,6 +353,8 @@ async def consume_sdk_stream(
)
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
+ if not terminal_seen:
+ raise RuntimeError("Responses stream ended before its terminal event")
provider_state = (
{OPENAI_RESPONSE_REASONING_ITEMS: reasoning_items} if reasoning_items else None
)
diff --git a/core/providers/profiles.py b/core/providers/profiles.py
index 1b910b83a..199f6e9d0 100644
--- a/core/providers/profiles.py
+++ b/core/providers/profiles.py
@@ -6,7 +6,8 @@
import json
import os
import re
-from dataclasses import dataclass
+from collections.abc import Callable
+from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any
from core.config import ConfigError, ManualModelConfig
@@ -14,12 +15,15 @@
from core.providers.base import GenerationSettings, LLMProvider
from core.providers.catalog import resolve_model_info
from core.providers.credentials import CredentialStore
-from core.providers.registry import PROVIDERS, ProviderSpec, find_by_model, find_by_name
+from core.providers.protocol_config import ProviderCompat, protocol_adapter
from core.providers.reasoning import (
ModelReasoningCapabilities,
infer_reasoning_capabilities,
+ declared_reasoning_capabilities,
resolve_reasoning_effort,
)
+from core.providers.registry import PROVIDERS, ProviderSpec, find_by_model, find_by_name
+from core.providers.revisions import ProviderRevisionStore, credential_digest
if TYPE_CHECKING:
from core.config import ConnectionProfileConfig, DeepCodeConfig
@@ -51,6 +55,10 @@ class ResolvedConnection:
# Full per-model declarations (label/capacities/efforts). ``manual_models``
# stays the plain id tuple existing consumers read; these carry the rest.
manual_model_entries: tuple[ManualModelConfig, ...] = ()
+ account_id: str | None = None
+ protocol: str = "auto"
+ auth: str = "api_key"
+ compat: ProviderCompat = field(default_factory=ProviderCompat)
def public_view(self) -> dict[str, Any]:
return {
@@ -58,6 +66,10 @@ def public_view(self) -> dict[str, Any]:
"label": self.label,
"providerName": self.provider_name,
"adapter": self.adapter,
+ "protocol": self.protocol,
+ "auth": self.auth,
+ "accountId": self.account_id,
+ "compat": self.compat.model_dump(by_alias=True, exclude_none=True),
"apiBase": self.api_base,
"apiKeyEnv": None,
"modelCatalog": self.model_catalog_setting,
@@ -76,9 +88,11 @@ def public_view(self) -> dict[str, Any]:
def is_configured(self) -> bool:
credential_ready = (
bool(self.api_key)
- or self.local
- or self.spec.is_direct
- or self.spec.is_oauth
+ or self.auth == "none"
+ or (
+ self.protocol == "auto"
+ and (self.local or self.spec.is_direct or self.spec.is_oauth)
+ )
)
endpoint_ready = not self.spec.requires_api_base or bool(self.api_base)
return credential_ready and endpoint_ready
@@ -95,9 +109,17 @@ def __init__(
self,
config: "DeepCodeConfig",
credentials: CredentialStore | None = None,
+ *,
+ config_loader: Callable[[], "DeepCodeConfig"] | None = None,
+ credential_overrides: dict[str, str | None] | None = None,
) -> None:
self.config = config
+ self.config_loader = config_loader
+ self._credential_overrides = dict(credential_overrides or {})
self.credentials = credentials or CredentialStore()
+ self.revisions = ProviderRevisionStore(
+ self.credentials.path.parent / "provider_revisions"
+ )
def list_connections(
self, *, include_unconfigured: bool = True
@@ -176,15 +198,22 @@ def execution_profile(
phase: str = "implementation",
model_limits: tuple[int, int] | None = None,
reasoning_capabilities: ModelReasoningCapabilities | None = None,
+ persist_revision: bool = True,
) -> ExecutionProfile:
normalized = (selection or ExecutionSelection()).normalized()
connection, model = self.resolve_selection(normalized, phase=phase)
settings = self.config.resolve_phase(phase)
+ entry = next(
+ (item for item in connection.manual_model_entries if item.id == model), None
+ )
info = resolve_model_info(model)
published_context_window, max_output_tokens = model_limits or (
info.context_window,
info.max_output_tokens,
)
+ if entry is not None:
+ published_context_window = entry.context_window or published_context_window
+ max_output_tokens = entry.max_output_tokens or max_output_tokens
if published_context_window < 1 or max_output_tokens < 1:
raise ConfigError(f"Invalid model limits for '{model}'")
max_tokens = min(settings.max_tokens, max_output_tokens)
@@ -205,14 +234,61 @@ def execution_profile(
model,
provider_name=connection.provider_name,
)
+ if entry is not None:
+ capabilities = declared_reasoning_capabilities(
+ entry.reasoning_efforts, capabilities
+ )
+ reasoning_supported = (
+ None
+ if entry is None or entry.reasoning_efforts is None
+ else entry.reasoning_efforts is not False
+ )
+ if reasoning_supported is False and normalized.reasoning_effort not in {
+ None,
+ "auto",
+ "none",
+ "off",
+ }:
+ raise ConfigError("This model is explicitly declared non-reasoning")
try:
reasoning_effort = resolve_reasoning_effort(
requested=normalized.reasoning_effort,
- configured=settings.reasoning_effort,
+ configured=settings.reasoning_effort
+ if reasoning_supported is not False
+ else None,
capabilities=capabilities,
)
except ValueError as exc:
raise ConfigError(str(exc)) from exc
+ compat = ProviderCompat.model_validate(
+ {
+ **connection.compat.model_dump(exclude_none=True),
+ **(
+ entry.compat.model_dump(exclude_none=True)
+ if entry is not None
+ else {}
+ ),
+ }
+ )
+ revision = (
+ self.revisions.put(
+ {
+ "schemaVersion": 1,
+ "connectionId": connection.id,
+ "providerName": connection.provider_name,
+ "adapter": connection.adapter,
+ "protocol": connection.protocol,
+ "auth": connection.auth,
+ "apiBase": connection.api_base,
+ "extraHeaders": connection.extra_headers,
+ "compat": compat.model_dump(by_alias=True, exclude_none=True),
+ "credentialDigest": credential_digest(connection.api_key),
+ "credentialAccount": connection.account_id,
+ }
+ )
+ if persist_revision
+ else None
+ )
return ExecutionProfile(
connection_id=connection.id,
provider_name=connection.provider_name,
@@ -224,6 +300,13 @@ def execution_profile(
temperature=settings.temperature,
reasoning_effort=reasoning_effort,
config_revision=self.connection_revision(connection),
+ protocol=connection.protocol,
+ provider_revision=revision,
+ input_modalities=tuple(entry.input_modalities)
+ if entry is not None and entry.input_modalities is not None
+ else None,
+ tool_calling=entry.tool_calling if entry is not None else None,
+ reasoning_supported=reasoning_supported,
)
def build_provider(self, profile: ExecutionProfile) -> LLMProvider:
@@ -236,6 +319,7 @@ def build_provider(self, profile: ExecutionProfile) -> LLMProvider:
api_base=connection.api_base,
default_model=profile.model_id,
extra_headers=connection.extra_headers,
+ compat=connection.compat,
)
elif connection.adapter == "openai_compat":
from core.providers.openai_compat import OpenAICompatProvider
@@ -246,12 +330,28 @@ def build_provider(self, profile: ExecutionProfile) -> LLMProvider:
default_model=profile.model_id,
extra_headers=connection.extra_headers,
spec=connection.spec,
+ protocol=connection.protocol,
+ compat=connection.compat,
+ auth_mode=connection.auth,
)
else:
raise ConfigError(
f"Unsupported adapter '{connection.adapter}' "
f"for connection '{connection.id}'"
)
+
+ def current_credential():
+ resolver = ConnectionResolver(
+ self.config_loader() if self.config_loader else self.config,
+ self.credentials,
+ credential_overrides=self._credential_overrides,
+ )
+ return resolver.connection_for_profile(profile).api_key
+
+ provider.request_guard = current_credential
+ provider.input_modalities = profile.input_modalities
+ provider.tool_calling = profile.tool_calling
+ provider.reasoning_supported = profile.reasoning_supported
provider.generation = GenerationSettings(
temperature=profile.temperature,
max_tokens=profile.max_tokens,
@@ -259,27 +359,81 @@ def build_provider(self, profile: ExecutionProfile) -> LLMProvider:
)
return provider
- def connection_for_profile(
- self,
- profile: ExecutionProfile,
- ) -> ResolvedConnection:
- """Resolve credentials while rejecting non-secret config drift."""
-
+ def connection_for_profile(self, profile: ExecutionProfile) -> ResolvedConnection:
+ """Resolve live credentials against a frozen private route, or legacy revision."""
connection = self.resolve_connection(
- profile.connection_id,
- model=profile.model_id,
+ profile.connection_id, model=profile.model_id
)
- if self.connection_revision(connection) != profile.config_revision:
- raise ConfigError(
- f"LLM connection '{connection.id}' changed after this Turn was "
- "accepted. Retry with the current Session selection."
- )
if not connection.is_usable:
raise ConfigError(
- f"LLM connection '{connection.id}' has no credential. "
- "Configure it in Desktop Settings or with `deepcode provider set`."
+ f"LLM connection '{connection.id}' is disabled or has no credential"
)
- return connection
+ if profile.provider_revision is None:
+ if self.connection_revision(connection) != profile.config_revision:
+ raise ConfigError(
+ f"LLM connection '{connection.id}' changed after this Turn was accepted. Retry with the current Session selection."
+ )
+ entry = next(
+ (
+ item
+ for item in connection.manual_model_entries
+ if item.id == profile.model_id
+ ),
+ None,
+ )
+ if entry is not None:
+ connection = replace(
+ connection,
+ compat=ProviderCompat.model_validate(
+ {
+ **connection.compat.model_dump(exclude_none=True),
+ **entry.compat.model_dump(exclude_none=True),
+ }
+ ),
+ )
+ return connection
+ try:
+ snapshot = self.revisions.get(profile.provider_revision)
+ if (
+ snapshot.get("schemaVersion") != 1
+ or snapshot.get("connectionId") != connection.id
+ or snapshot.get("providerName") != connection.provider_name
+ or snapshot.get("auth") != connection.auth
+ or snapshot.get("protocol") != profile.protocol
+ or snapshot.get("adapter") != profile.adapter
+ or snapshot.get("credentialAccount") != connection.account_id
+ ):
+ raise ValueError("Provider identity changed after admission")
+ if connection.extra_headers != snapshot["extraHeaders"]:
+ raise ValueError(
+ "Header credentials or configuration changed after this Turn was accepted; resubmit with current settings"
+ )
+ if connection.api_key is None and snapshot[
+ "credentialDigest"
+ ] != credential_digest(None):
+ raise ValueError("The credential used by this Turn has been removed")
+ if (
+ credential_digest(connection.api_key) != snapshot["credentialDigest"]
+ and connection.api_base != snapshot["apiBase"]
+ ):
+ raise ValueError(
+ "Credential and endpoint changed after this Turn was accepted"
+ )
+ return replace(
+ connection,
+ adapter=snapshot["adapter"],
+ protocol=snapshot["protocol"],
+ api_base=snapshot["apiBase"],
+ extra_headers=dict(snapshot["extraHeaders"]),
+ compat=ProviderCompat.model_validate(snapshot["compat"]),
+ manual_model_entries=(),
+ )
+ except (ValueError, KeyError, TypeError) as exc:
+ raise ConfigError(
+ str(exc)
+ if isinstance(exc, ValueError)
+ else "Invalid private provider revision; resubmit with current settings"
+ ) from exc
@staticmethod
def connection_revision(connection: ResolvedConnection) -> str:
@@ -293,6 +447,17 @@ def connection_revision(connection: ResolvedConnection) -> str:
"apiBase": connection.api_base,
"extraHeaders": connection.extra_headers,
"enabled": connection.enabled,
+ **(
+ {"protocol": connection.protocol}
+ if connection.protocol != "auto"
+ else {}
+ ),
+ **({"auth": connection.auth} if connection.auth != "api_key" else {}),
+ **(
+ {"compat": connection.compat.model_dump(exclude_none=True)}
+ if connection.compat.model_dump(exclude_none=True)
+ else {}
+ ),
},
sort_keys=True,
separators=(",", ":"),
@@ -334,20 +499,28 @@ def _profile_connection(
legacy_key=legacy_key,
key_optional=spec.is_local or spec.is_direct or spec.is_oauth,
)
+ account_id = None
+ if profile.auth == "oauth":
+ api_key, account_id = self.credentials.oauth_credential(connection_id)
+ source = "oauth" if api_key else "missing"
model_entries = _clean_model_entries(profile.manual_models)
return ResolvedConnection(
id=connection_id,
label=profile.label.strip() or connection_id,
provider_name=spec.name,
- adapter=profile.adapter or spec.backend,
- api_key=api_key,
+ adapter=protocol_adapter(profile.protocol, profile.adapter or spec.backend),
+ protocol=profile.protocol,
+ account_id=account_id,
+ auth=profile.auth,
+ compat=profile.compat,
+ api_key=api_key if profile.auth != "none" else None,
api_base=profile.api_base or spec.default_api_base or None,
extra_headers=dict(profile.extra_headers),
- model_catalog=_catalog_kind(profile.model_catalog, spec),
+ model_catalog=_catalog_kind(profile.model_catalog, spec, profile.protocol),
model_catalog_setting=profile.model_catalog,
manual_models=tuple(entry.id for entry in model_entries),
manual_model_entries=model_entries,
- credential_source=source,
+ credential_source=source if profile.auth != "none" else "not_required",
local=spec.is_local,
enabled=profile.enabled,
spec=spec,
@@ -367,6 +540,11 @@ def template_connection(self, template: str) -> ResolvedConnection:
def _legacy_connection(self, spec: ProviderSpec) -> ResolvedConnection:
provider = getattr(self.config.providers, spec.name)
+ if (
+ provider.auth == "none"
+ and protocol_adapter(provider.protocol, spec.backend) == "anthropic"
+ ):
+ raise ConfigError("Anthropic Messages requires an API key")
api_key, source = self._credential(
spec.name,
env_name=spec.env_key,
@@ -377,14 +555,17 @@ def _legacy_connection(self, spec: ProviderSpec) -> ResolvedConnection:
id=spec.name,
label=spec.label,
provider_name=spec.name,
- adapter=spec.backend,
- api_key=api_key,
+ adapter=protocol_adapter(provider.protocol, spec.backend),
+ protocol=provider.protocol,
+ auth=provider.auth,
+ compat=provider.compat,
+ api_key=api_key if provider.auth != "none" else None,
api_base=provider.api_base or spec.default_api_base or None,
extra_headers=dict(provider.extra_headers or {}),
- model_catalog=_catalog_kind("auto", spec),
+ model_catalog=_catalog_kind("auto", spec, provider.protocol),
model_catalog_setting="auto",
manual_models=(),
- credential_source=source,
+ credential_source=source if provider.auth != "none" else "not_required",
local=spec.is_local,
enabled=True,
spec=spec,
@@ -398,6 +579,9 @@ def _credential(
legacy_key: str | None,
key_optional: bool,
) -> tuple[str | None, str]:
+ if connection_id in self._credential_overrides:
+ value = self._credential_overrides[connection_id]
+ return value, "request" if value else "missing"
if env_name and os.environ.get(env_name):
return os.environ[env_name], "environment"
stored = self.credentials.get(connection_id)
@@ -433,9 +617,13 @@ def _clean_model_entries(
return tuple(entries.values())
-def _catalog_kind(configured: str, spec: ProviderSpec) -> str:
+def _catalog_kind(configured: str, spec: ProviderSpec, protocol: str = "auto") -> str:
if configured != "auto":
return configured
+ if protocol == "anthropic_messages":
+ return "anthropic"
+ if protocol in {"openai_chat", "openai_responses"} and spec.backend == "anthropic":
+ return "openai"
if spec.name == "openrouter":
return "openrouter"
if spec.name == "anthropic":
diff --git a/core/providers/protocol_config.py b/core/providers/protocol_config.py
new file mode 100644
index 000000000..a66df6031
--- /dev/null
+++ b/core/providers/protocol_config.py
@@ -0,0 +1,127 @@
+"""Explicit provider wire choices; every compatibility field has an encoder."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+ProviderProtocol = Literal[
+ "auto", "openai_chat", "openai_responses", "anthropic_messages"
+]
+
+
+class ProviderCompat(BaseModel):
+ model_config = ConfigDict(
+ alias_generator=to_camel,
+ populate_by_name=True,
+ extra="forbid",
+ frozen=True,
+ hide_input_in_errors=True,
+ )
+
+ token_limit_field: Literal["max_tokens", "max_completion_tokens"] | None = None
+ temperature: bool | None = None
+ system_role: Literal["system", "developer", "user"] | None = None
+ reasoning_field: Literal["reasoning_effort", "reasoning", "omit"] | None = None
+ reasoning_content: Literal["preserve", "empty", "omit"] | None = None
+ tool_message_name: bool | None = None
+ parallel_tool_calls: bool | None = None
+
+ def validate_protocol(self, protocol: str) -> None:
+ supplied = self.model_dump(exclude_none=True)
+ if not supplied:
+ return
+ if protocol == "openai_chat":
+ return
+ if protocol == "anthropic_messages" and set(supplied) <= {"temperature"}:
+ return
+ if protocol == "openai_responses" and set(supplied) <= {
+ "temperature",
+ "reasoning_field",
+ "parallel_tool_calls",
+ }:
+ if self.reasoning_field != "reasoning_effort":
+ return
+ raise ValueError(
+ "Compatibility overrides require an explicit matching protocol; the selected fields are unsupported for this protocol"
+ )
+
+
+def protocol_adapter(protocol: str, legacy_adapter: str) -> str:
+ if protocol == "auto":
+ return legacy_adapter
+ return "anthropic" if protocol == "anthropic_messages" else "openai_compat"
+
+
+def apply_chat_compat(
+ body: dict,
+ compat: ProviderCompat,
+ *,
+ max_tokens: int,
+ temperature: float,
+ reasoning_effort: str | None,
+) -> dict:
+ """Apply explicit overrides after the built-in model defaults, without user JSON merging."""
+ if compat.token_limit_field is not None:
+ body.pop("max_tokens", None)
+ body.pop("max_completion_tokens", None)
+ body[compat.token_limit_field] = max(1, max_tokens)
+ if compat.temperature is False:
+ body.pop("temperature", None)
+ elif compat.temperature is True:
+ body["temperature"] = temperature
+ messages = [dict(message) for message in body["messages"]]
+ if compat.system_role is not None:
+ for message in messages:
+ if message.get("role") in {"system", "developer"}:
+ message["role"] = compat.system_role
+ if compat.reasoning_field is not None:
+ body.pop("reasoning_effort", None)
+ extra = dict(body.get("extra_body", {}))
+ for key in ("reasoning", "thinking", "enable_thinking", "reasoning_split"):
+ extra.pop(key, None)
+ effort = reasoning_effort.lower() if reasoning_effort else None
+ if compat.reasoning_field == "reasoning_effort" and effort not in {
+ None,
+ "auto",
+ }:
+ body["reasoning_effort"] = effort
+ elif compat.reasoning_field == "reasoning" and effort not in {None, "auto"}:
+ extra["reasoning"] = (
+ {"enabled": False} if effort == "none" else {"effort": effort}
+ )
+ if extra:
+ body["extra_body"] = extra
+ else:
+ body.pop("extra_body", None)
+ if compat.reasoning_content is not None:
+ for message in messages:
+ if message.get("role") == "assistant":
+ if compat.reasoning_content == "empty":
+ message.setdefault("reasoning_content", "")
+ elif compat.reasoning_content == "omit":
+ message.pop("reasoning_content", None)
+ if compat.tool_message_name is not None:
+ calls = {
+ call["id"]: call.get("function", {}).get("name")
+ for message in messages
+ for call in message.get("tool_calls", [])
+ if isinstance(call, dict) and "id" in call
+ }
+ for message in messages:
+ if message.get("role") == "tool":
+ if not compat.tool_message_name:
+ message.pop("name", None)
+ elif not message.get("name"):
+ name = calls.get(message.get("tool_call_id"))
+ if not name:
+ raise ValueError(
+ "The configured protocol requires a tool result name, but its matching tool call is missing"
+ )
+ message["name"] = name
+ if compat.parallel_tool_calls is not None and body.get("tools"):
+ body["parallel_tool_calls"] = compat.parallel_tool_calls
+ body["messages"] = messages
+ return body
diff --git a/core/providers/reasoning.py b/core/providers/reasoning.py
index c7ad114c3..e181915b8 100644
--- a/core/providers/reasoning.py
+++ b/core/providers/reasoning.py
@@ -95,6 +95,25 @@ def from_dict(cls, value: Any) -> "ModelReasoningCapabilities | None":
return None
+def declared_reasoning_capabilities(
+ efforts: list[str] | bool | None,
+ fallback: ModelReasoningCapabilities | None,
+) -> ModelReasoningCapabilities | None:
+ """Resolve the same manual capability declaration for catalog and execution."""
+ if efforts is None:
+ return fallback
+ if efforts is False:
+ return ModelReasoningCapabilities()
+ levels = tuple(
+ dict.fromkeys(level.strip().lower() for level in efforts if level.strip())
+ )
+ return ModelReasoningCapabilities(
+ supported_efforts=tuple(level for level in levels if level != "off"),
+ default_enabled=True,
+ mandatory="off" not in levels,
+ )
+
+
def resolve_reasoning_effort(
*,
requested: str | None,
diff --git a/core/providers/revisions.py b/core/providers/revisions.py
new file mode 100644
index 000000000..da63f53f7
--- /dev/null
+++ b/core/providers/revisions.py
@@ -0,0 +1,73 @@
+"""Private, content-addressed provider routes referenced by admitted Turns."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+from pathlib import Path
+
+from core.file_lock import exclusive_file_lock
+from core.private_storage import atomic_write_private_json, open_existing_private_file
+
+
+def credential_digest(value: str | None) -> str:
+ return hashlib.sha256((value or "").encode()).hexdigest()
+
+
+class ProviderRevisionStore:
+ """Keep route/header snapshots private; API key bodies are never stored here."""
+
+ def __init__(self, directory: Path):
+ self.directory = directory
+
+ @staticmethod
+ def _identity(value: dict) -> str:
+ return hashlib.sha256(
+ json.dumps(
+ value,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ allow_nan=False,
+ ).encode()
+ ).hexdigest()
+
+ def put(self, value: dict) -> str:
+ if len(json.dumps(value).encode()) > 65536:
+ raise ValueError("Resolved provider configuration exceeds 64 KiB")
+ identity = self._identity(value)
+ path = self.directory / f"{identity}.json"
+ if path.exists():
+ self.get(identity)
+ return identity
+ with exclusive_file_lock(self.directory / "write.lock"):
+ if path.exists():
+ self.get(identity)
+ else:
+ atomic_write_private_json(path, value)
+ return identity
+
+ def get(self, identity: str) -> dict:
+ if not re.fullmatch(r"[a-f0-9]{64}", identity):
+ raise ValueError("Invalid provider revision identity")
+ try:
+ with os.fdopen(
+ open_existing_private_file(self.directory / f"{identity}.json"),
+ "r",
+ encoding="utf-8",
+ ) as stream:
+ raw = stream.read(65537)
+ value = json.loads(raw)
+ if (
+ len(raw) > 65536
+ or not isinstance(value, dict)
+ or self._identity(value) != identity
+ ):
+ raise ValueError("Invalid provider revision")
+ return value
+ except (OSError, ValueError) as exc:
+ raise ValueError(
+ "The private provider revision is missing or invalid; resubmit with the current configuration"
+ ) from exc
diff --git a/core/sessions/store.py b/core/sessions/store.py
index 6b12a89e5..f500b1aa8 100644
--- a/core/sessions/store.py
+++ b/core/sessions/store.py
@@ -80,6 +80,12 @@ def __init__(
self._disk_signatures: dict[str, tuple[int, int, int, int]] | None = None
self._deletions = SessionDeletionJournal(self.root)
+ def close(self) -> None:
+ """Release this store's index connection before offline file operations."""
+ with self._lock:
+ if self._index is not None:
+ self._index.close()
+
# ------------------------------------------------------------------
# Path helpers
# ------------------------------------------------------------------
diff --git a/deepcode.py b/deepcode.py
index 4e8816361..b3eae5cf7 100755
--- a/deepcode.py
+++ b/deepcode.py
@@ -162,6 +162,22 @@ def main():
from cli.provider_cli import run as provider_run
raise SystemExit(provider_run(sys.argv[2:]))
+ elif sys.argv[1] == "service":
+ from cli.service_cli import run as service_run
+
+ raise SystemExit(service_run(sys.argv[2:]))
+ elif sys.argv[1] == "desktop":
+ from cli.desktop_cli import run as desktop_run
+
+ raise SystemExit(desktop_run(sys.argv[2:]))
+ elif sys.argv[1] == "web":
+ from cli.web_cli import run as web_run
+
+ raise SystemExit(web_run(sys.argv[2:]))
+ elif sys.argv[1] == "serve":
+ from app_server.service import main as serve_main
+
+ raise SystemExit(serve_main(sys.argv[2:]))
elif sys.argv[1] in {"session", "sessions"}:
from cli.session_cli import run as session_run
@@ -197,7 +213,9 @@ def row(cmd, desc):
[
"",
"Usage:",
- row("deepcode", "Interactive coding agent (TUI, default)"),
+ row("deepcode", "Open the terminal coding agent (TUI)"),
+ row("deepcode desktop", "Open the native desktop client"),
+ row("deepcode web", "Open the local browser client"),
row(
"deepcode init",
"Set up ~/.deepcode so deepcode runs anywhere",
@@ -205,7 +223,15 @@ def row(cmd, desc):
row("deepcode test ", "Test paper reproduction"),
row("deepcode test --fast", "Test paper (fast mode)"),
row("deepcode mcp serve", "Expose DeepCode as an MCP server"),
- row("deepcode mcp list|add|remove", "Manage MCP clients"),
+ row(
+ "deepcode service ",
+ "Manage the background service",
+ ),
+ row(
+ "deepcode serve --foreground",
+ "Run the service in this terminal",
+ ),
+ row("deepcode mcp ", "Connect and manage MCP tools"),
row(
"deepcode skill ",
"List, inspect, import, and manage Agent Skills",
@@ -243,29 +269,20 @@ def row(cmd, desc):
"Manage durable Agent Automations",
),
"",
- " More agent entry points:",
- row(
- 'python -m cli.exec_cli "" -w .',
- "Headless one-shot run",
- ),
- row(
- 'python -m cli.loop_cli ""',
- "Headless durable Goal",
- ),
- row(
- "python -m cli.loop_cli --resume ",
- "Resume a durable Goal without replacing history",
- ),
+ "Examples:",
+ row("deepcode --trust", "Start in a project you trust"),
row(
- "python -m cli.schedule_cli ...",
- "Scheduled / keepalive runs",
+ "deepcode --resume ",
+ "Continue a saved conversation",
),
- "",
- "Examples:",
- row("deepcode", "Drop into the interactive agent"),
+ row("deepcode provider list", "Show model connections"),
+ row("deepcode service status", "Check the background service"),
row("deepcode test rice", "Test RICE paper reproduction"),
row("deepcode test rice --fast", "Test RICE paper (fast mode)"),
"",
+ "Run 'deepcode --help' for command options.",
+ "Run 'deepcode chat --help' for TUI options.",
+ "",
"Available papers:",
]
)
diff --git a/desktop/README.md b/desktop/README.md
index a24bdc9b1..772584f01 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -10,6 +10,20 @@ approvals, Git review, files, terminals, tests, Artifacts, and provider
settings. Work started in one interface can be resumed in the other without
converting or copying its Session.
+## Start Desktop
+
+After installing the CLI, run:
+
+```console
+deepcode desktop
+```
+
+A local-source CLI installation launches its recorded checkout and prepares
+missing development resources. A published CLI opens an installed Desktop app.
+Use `deepcode desktop --source /path/to/DeepCode` to choose a checkout explicitly,
+or `deepcode desktop --app /path/to/application` for a custom app location.
+See the [main installation guide](../README.md#install-the-runtime) for setup.
+
## Run from source
### Requirements
@@ -61,65 +75,51 @@ rustc --version
cargo --version
```
-#### 3. Prepare the repository
+#### 3. Install the source checkout
-Run these commands from the repository root:
+From the repository root, build Web assets and register the global CLI:
```powershell
-uv venv --python 3.12
-uv pip install --python .venv\Scripts\python.exe -e .
-Set-Location desktop
-npm ci
-$env:DEEPCODE_PYTHON = (Resolve-Path ..\.venv\Scripts\python.exe)
-npm run setup:sidecar
-npm run build:sidecar
+npm --prefix desktop ci
+npm --prefix desktop run build:web
+uv tool install --python 3.12 --force .
+deepcode init
```
-The explicit interpreter path prevents an active Conda or other environment
-from receiving the editable DeepCode installation.
+If `deepcode` is not on PATH, run `uv tool update-shell` and reopen PowerShell.
#### 4. Start DeepCode Desktop
-From the same `desktop` directory and PowerShell window:
-
```powershell
-npm run tauri -- dev
+deepcode desktop
```
-Keep the terminal open while Desktop is running. Press `Ctrl+C` in that terminal
-to stop the development application. On later launches, set `DEEPCODE_PYTHON`
-again if using a new PowerShell window:
-
-```powershell
-Set-Location desktop
-$env:DEEPCODE_PYTHON = (Resolve-Path ..\.venv\Scripts\python.exe)
-npm run tauri -- dev
-```
+The first launch prepares missing Desktop resources. Later launches reuse them.
+Keep this development terminal open while Desktop is running. Closing the
+window or pressing Ctrl+C closes the client; the shared service remains running.
### macOS and Linux
-After installing the platform dependencies from the Tauri prerequisite guide,
-run the following commands from the repository root:
+Install the platform dependencies from the Tauri prerequisite guide. From the
+repository root, install the current checkout once:
```bash
-uv venv --python 3.12
-uv pip install --python .venv/bin/python -e .
-cd desktop
-npm ci
-npm run setup:sidecar
-npm run build:sidecar
-cd ..
-./scripts/deepcode-desktop
+npm --prefix desktop ci
+npm --prefix desktop run build:web
+uv tool install --python 3.12 --force .
+deepcode init
```
-To use `deepcode-desktop` from any working directory, link the launcher into a
-directory on `PATH` once:
+Then start the application from any directory:
```bash
-mkdir -p ~/.local/bin
-ln -sf "$(pwd)/scripts/deepcode-desktop" ~/.local/bin/deepcode-desktop
+deepcode desktop
```
+Use `deepcode desktop --setup` to rebuild source dependencies and Desktop
+resources. To select a different checkout explicitly, use
+`deepcode desktop --source /path/to/DeepCode`.
+
### Development sidecar
The first `build:sidecar` creates the resource directory declared in
@@ -153,6 +153,10 @@ runtime dependencies.
6. Open a Session to inherit the default, or use its composer picker to switch
the connection/model for future Turns.
+The model check above is minimal inference. To also check streaming and a local
+tool round trip, run `deepcode provider test CONNECTION_ID --model MODEL_ID --agent`
+with the configured IDs. See [Models and providers](../docs/guide/models.md).
+
API keys are written to `~/.deepcode/credentials.json` in user-private storage.
Desktop receives only configured/missing status and never reads a stored key
back. The same named connections and defaults are used by CLI and Desktop.
@@ -257,12 +261,13 @@ the next explicit page with stable ID deduplication. Live notifications refresh
the first page; an overflow warning also resets the visible pages safely rather
than presenting a partial cache as complete.
-Interval schedules execute while a scheduler-enabled Desktop or App Server is
-active. If several compatible processes share the database, one holds the
-scheduler leader lease and the others remain available for takeover. Agent and
-Workflow Turns still use the shared cross-process capacity and workspace
-fences, so another Session cannot mutate the same canonical checkout at the
-same time.
+The shared background service owns interval scheduling and its scheduler
+leadership lease. Schedules continue after Desktop, TUI, and Web close, provided
+the service and computer remain running. Agent and Workflow Turns still obey
+execution capacity and workspace fences, preventing concurrent mutations of the
+same canonical checkout. Inspect the service with `deepcode service status`;
+see [Automations](../docs/guide/goals-and-headless.md#persistent-interval-automations)
+for manual runs and pausing future submissions.
Automation instructions never grant trust or elevated permissions. Each Turn
captures the workspace's explicit permission setting or the safe default, and
diff --git a/desktop/e2e/web.spec.ts b/desktop/e2e/web.spec.ts
new file mode 100644
index 000000000..b521cba54
--- /dev/null
+++ b/desktop/e2e/web.spec.ts
@@ -0,0 +1,400 @@
+import { test, expect } from "@playwright/test";
+import {
+ execFileSync,
+ spawn,
+ spawnSync,
+ type ChildProcess,
+} from "node:child_process";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const repository = fileURLToPath(new URL("../../", import.meta.url));
+const python =
+ process.env.DEEPCODE_TEST_PYTHON ??
+ join(
+ repository,
+ ".venv",
+ process.platform === "win32" ? "Scripts/python.exe" : "bin/python",
+ );
+const live = process.env.DEEPCODE_WEB_LIVE === "1";
+const installed = process.env.DEEPCODE_WEB_PACKAGE_DIR;
+const standalone = process.env.DEEPCODE_WEB_BINARY;
+let root: string,
+ workspace: string,
+ environment: NodeJS.ProcessEnv,
+ processHandle: ChildProcess;
+const cwd = () => (installed ? root : repository);
+const command = (...args: string[]) =>
+ execFileSync(python, args, {
+ cwd: cwd(),
+ env: environment,
+ encoding: "utf8",
+ timeout: 40000,
+ });
+function link() {
+ if (standalone)
+ return JSON.parse(
+ execFileSync(
+ standalone,
+ [
+ "--web",
+ "--database",
+ join(root, "state.sqlite3"),
+ "--port",
+ "0",
+ "--no-open",
+ "--json",
+ ],
+ { cwd: root, env: environment, encoding: "utf8", timeout: 40000 },
+ ),
+ ).url as string;
+ return JSON.parse(
+ command(
+ "-m",
+ "deepcode",
+ "web",
+ "--database",
+ join(root, "state.sqlite3"),
+ "--no-open",
+ "--json",
+ ),
+ ).url as string;
+}
+
+test.beforeAll(async () => {
+ root = mkdtempSync(join(tmpdir(), "deepcode-web-e2e-"));
+ workspace = join(root, "workspace");
+ mkdirSync(workspace);
+ mkdirSync(join(root, "home"));
+ writeFileSync(join(workspace, "example.py"), "answer = 41\n");
+ for (const args of [
+ ["init", "-q"],
+ ["config", "user.name", "Web test"],
+ ["config", "user.email", "web-test@example.invalid"],
+ ["add", "."],
+ ["commit", "-qm", "fixture"],
+ ])
+ execFileSync("git", args, { cwd: workspace });
+ writeFileSync(join(workspace, "example.py"), "answer = 42\n");
+ if (live)
+ execFileSync(
+ python,
+ [
+ "-m",
+ "tests.app_server.web_worker",
+ join(root, "home", "deepcode_config.json"),
+ "--prepare-live-config",
+ ],
+ { cwd: repository },
+ );
+ environment = {
+ ...process.env,
+ DEEPCODE_HOME: join(root, "home"),
+ DEEPCODE_SESSIONS_DIR: join(root, "home", "sessions"),
+ ...(installed ? { PYTHONPATH: installed } : {}),
+ };
+ for (const key of Object.keys(environment))
+ if (/_API_KEY$/.test(key)) delete environment[key];
+ if (standalone) link();
+ else
+ processHandle = spawn(
+ python,
+ installed
+ ? [
+ "-m",
+ "app_server.service",
+ "--database",
+ join(root, "state.sqlite3"),
+ "--port",
+ "0",
+ ]
+ : [
+ "-m",
+ "tests.app_server.web_worker",
+ root,
+ ...(live ? ["--live"] : []),
+ ],
+ { cwd: cwd(), env: environment, stdio: "ignore" },
+ );
+ await expect
+ .poll(
+ () => existsSync(join(root, "state.sqlite3.service", "instance.json")),
+ { timeout: 15000 },
+ )
+ .toBe(true);
+});
+
+test.afterAll(() => {
+ try {
+ command(
+ "-m",
+ "deepcode",
+ "service",
+ "stop",
+ "--database",
+ join(root, "state.sqlite3"),
+ "--cancel-running",
+ "--timeout",
+ "2",
+ "--json",
+ );
+ } finally {
+ if (processHandle && processHandle.exitCode === null)
+ processHandle.kill("SIGTERM");
+ if (root) rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("packaged browser: project, approval, reconnect, upload, diff and terminal", async ({
+ page,
+ context,
+}, testInfo) => {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ page.on("dialog", (dialog) => dialog.accept());
+ await page.goto(link());
+ await expect(
+ page
+ .getByRole("button", { name: "Open project folder", exact: true })
+ .first(),
+ ).toBeEnabled();
+ expect(page.url()).not.toContain("ticket=");
+ await page
+ .getByRole("button", { name: "Open project folder", exact: true })
+ .first()
+ .click();
+ await page.getByLabel("Project folder", { exact: true }).fill(workspace);
+ await page.getByRole("button", { name: "Open project", exact: true }).click();
+ await page.getByRole("button", { name: "Trust folder", exact: true }).click();
+ await expect(
+ page.getByText("Trusted", { exact: true }).first(),
+ ).toBeVisible();
+ // Source-controlled labels make this independent of generated CSS names.
+ await page
+ .getByRole("button", { name: /New (thread|session|task)/i })
+ .first()
+ .click();
+ const composer = page
+ .getByRole("textbox", { name: /message|prompt|instruction/i })
+ .first();
+ await expect(composer).toBeEnabled();
+
+ const attachment = join(root, "context.txt");
+ writeFileSync(
+ attachment,
+ "Browser upload acceptance: preserve the public API.\n",
+ );
+ const chooser = page.waitForEvent("filechooser");
+ await page.getByRole("button", { name: "Attach workspace files" }).click();
+ await (await chooser).setFiles(attachment);
+ await expect(page.getByLabel("Attached context files")).toContainText(
+ "context.txt",
+ );
+
+ if (!installed && !standalone) {
+ await composer.fill(
+ live
+ ? "Work only in this workspace. Create arithmetic.py with triangular(n), sum 1 through n, rejecting negative n. Create test_arithmetic.py with standard-library unittest for 0, 1, 10, 100 and negative input. Use write for these two files, then run exactly python3 -m unittest -v. No dependencies, network, git changes or subagents. Finish after tests pass."
+ : "Verify the browser approval flow and finish.",
+ );
+ await composer.press("Enter");
+ if (live) {
+ await expect(page.getByLabel("Thread conversation")).toContainText(
+ "Create arithmetic.py",
+ );
+ await page.reload();
+ const deadline = Date.now() + 240000;
+ while (Date.now() < deadline) {
+ const approve = page.getByRole("button", {
+ name: "Allow once",
+ exact: true,
+ });
+ if (await approve.count()) {
+ const card = approve.first().locator("..").locator("..");
+ await card.getByText("Review arguments", { exact: true }).click();
+ const args = JSON.parse(await card.locator("pre").innerText());
+ const tool = await card.locator("strong").first().innerText();
+ if (tool === "write") {
+ expect([
+ join(workspace, "arithmetic.py"),
+ join(workspace, "test_arithmetic.py"),
+ ]).toContain(resolve(workspace, args.file_path));
+ } else {
+ expect(tool).toBe("bash");
+ expect(["python3 -m unittest -v", "pwd", "ls"]).toContain(
+ args.command.trim(),
+ );
+ }
+ await approve.first().click();
+ }
+ if (
+ existsSync(join(workspace, "test_arithmetic.py")) &&
+ (await page
+ .getByRole("button", { name: /Stop (turn|task|generation)/i })
+ .count()) === 0 &&
+ (await approve.count()) === 0
+ )
+ break;
+ await page.waitForTimeout(500);
+ }
+ expect(existsSync(join(workspace, "arithmetic.py"))).toBe(true);
+ await expect(
+ page.getByText(/^Worked for|^Work completed/).first(),
+ ).toBeVisible();
+ const result = spawnSync(python, ["-m", "unittest", "-v"], {
+ cwd: workspace,
+ encoding: "utf8",
+ });
+ expect(result.status).toBe(0);
+ await testInfo.attach("generated-code", {
+ body: readFileSync(join(workspace, "arithmetic.py")),
+ contentType: "text/plain",
+ });
+ await testInfo.attach("independent-tests", {
+ body: result.stdout + result.stderr,
+ contentType: "text/plain",
+ });
+ } else {
+ await expect(
+ page.getByRole("button", { name: "Allow once", exact: true }),
+ ).toBeVisible();
+ await page.reload();
+ await expect(
+ page.getByRole("button", { name: "Allow once", exact: true }),
+ ).toBeVisible();
+ await context.setOffline(true);
+ await context.setOffline(false);
+ await page
+ .getByRole("button", { name: "Allow once", exact: true })
+ .click();
+ await expect(
+ page.getByText("done", { exact: true }).first(),
+ ).toBeVisible();
+ }
+ const admissions = JSON.parse(
+ command(
+ "-c",
+ "import sqlite3,json,sys; c=sqlite3.connect(sys.argv[1]); print(json.dumps(c.execute(\"select count(*) from event_log where type in ('turn.started','turn.queued')\").fetchone()[0]))",
+ join(root, "state.sqlite3"),
+ ),
+ );
+ expect(admissions).toBe(1);
+ await testInfo.attach("task-admissions", {
+ body: JSON.stringify({ admissions }),
+ contentType: "application/json",
+ });
+ }
+
+ await page.reload();
+ await expect(
+ page.getByText("Trusted", { exact: true }).first(),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ await expect(
+ page.getByRole("heading", { name: "Service updates" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Copy configuration path" }),
+ ).toBeVisible();
+ await page
+ .getByRole("combobox", { name: "Default Session access", exact: true })
+ .selectOption("read_only");
+ await page
+ .getByRole("button", { name: "Save safety settings", exact: true })
+ .click();
+ await expect(page.getByText(/Effective default: Read only/)).toBeVisible();
+ await page
+ .getByRole("combobox", { name: "Default Session access", exact: true })
+ .selectOption("ask");
+ await page
+ .getByRole("button", { name: "Save safety settings", exact: true })
+ .click();
+ await expect(page.getByText(/Effective default: Ask/)).toBeVisible();
+ await page
+ .getByRole("button", { name: "Close settings", exact: true })
+ .click();
+ await page
+ .getByRole("button", { name: /Review/ })
+ .first()
+ .click();
+ await expect(
+ page.getByText("example.py", { exact: true }).first(),
+ ).toBeVisible();
+ await page
+ .getByRole("button", { name: "Open in editor", exact: true })
+ .first()
+ .click();
+ const downloaded = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Download", exact: true }).click();
+ const file = await downloaded;
+ expect(readFileSync((await file.path())!, "utf8")).toBe("answer = 42\n");
+ await page.getByRole("tab", { name: "terminal", exact: true }).click();
+ await page
+ .getByRole("button", { name: "Start terminal", exact: true })
+ .click();
+ await expect(page.getByText(/^PID \d+$/)).toBeVisible();
+ const pid = await page.getByText(/^PID \d+$/).innerText();
+ const terminal = page.locator(".xterm-helper-textarea");
+ await terminal.pressSequentially("printf 'once\\n' >> browser-pty.txt");
+ await terminal.press("Enter");
+ await expect
+ .poll(() => existsSync(join(workspace, "browser-pty.txt")))
+ .toBe(true);
+ expect(readFileSync(join(workspace, "browser-pty.txt"), "utf8")).toBe(
+ "once\n",
+ );
+ await page.reload();
+ await page.getByRole("button", { name: "Review", exact: true }).click();
+ await page.getByRole("tab", { name: "terminal", exact: true }).click();
+ await expect(page.getByText(pid, { exact: true })).toBeVisible();
+ expect(readFileSync(join(workspace, "browser-pty.txt"), "utf8")).toBe(
+ "once\n",
+ );
+ await page.screenshot({
+ path: testInfo.outputPath("web-ready.png"),
+ fullPage: true,
+ });
+ expect(errors).toEqual([]);
+ await page.getByRole("button", { name: "Sign out", exact: true }).click();
+ await expect(page.getByText(/Signed out/).first()).toBeVisible();
+});
+
+
+test("browser access: missing and used links, sign-out, and fresh-link recovery", async ({ page, context }) => {
+ const accessLink = link();
+ const base = new URL(accessLink).origin;
+ await page.goto(base);
+ const notice = page.getByRole("alert");
+ await expect(notice).toContainText("Browser access required");
+ await expect(notice).toContainText("deepcode web");
+ await expect(notice).not.toContainText("APP_SERVER_OFFLINE");
+ await expect(notice.getByRole("button", { name: "Reconnect" })).toHaveCount(0);
+ await expect(notice.locator("span")).toHaveCSS("white-space", "normal");
+
+ await page.goto(accessLink);
+ await expect(page.getByRole("button", { name: "Open project folder", exact: true }).first()).toBeEnabled();
+ await expect(notice).toHaveCount(0);
+
+ await context.clearCookies();
+ await page.goto(accessLink);
+ await expect(notice).toContainText("Browser access required");
+ await page.goto(link());
+ await expect(page.getByRole("button", { name: "Open project folder", exact: true }).first()).toBeEnabled();
+ await expect(notice).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Sign out", exact: true }).click();
+ await expect(notice).toContainText("Browser access required");
+ await expect(notice.getByRole("button", { name: "Reconnect" })).toHaveCount(0);
+ await page.goto(link());
+ await expect(page.getByRole("button", { name: "Open project folder", exact: true }).first()).toBeEnabled();
+ await expect(notice).toHaveCount(0);
+});
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index e9403f985..723cbe9e7 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -27,6 +27,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.58.2",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
@@ -42,7 +43,7 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.64.0",
"vite": "^8.1.4",
- "vitest": "^4.1.10"
+ "vitest": "^4.1.11"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
@@ -871,6 +872,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -1874,16 +1891,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
- "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.10",
- "@vitest/utils": "4.1.10",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1892,13 +1909,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
- "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.10",
+ "@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1919,9 +1936,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
- "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1932,13 +1949,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
- "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.10",
+ "@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -1946,14 +1963,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
- "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.10",
- "@vitest/utils": "4.1.10",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1962,9 +1979,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
- "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1972,13 +1989,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
- "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.10",
+ "@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -3080,9 +3097,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
- "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -4670,6 +4687,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
@@ -5140,9 +5204,9 @@
}
},
"node_modules/tinyrainbow": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
- "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5548,19 +5612,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
- "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.10",
- "@vitest/mocker": "4.1.10",
- "@vitest/pretty-format": "4.1.10",
- "@vitest/runner": "4.1.10",
- "@vitest/snapshot": "4.1.10",
- "@vitest/spy": "4.1.10",
- "@vitest/utils": "4.1.10",
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -5588,12 +5652,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.10",
- "@vitest/browser-preview": "4.1.10",
- "@vitest/browser-webdriverio": "4.1.10",
- "@vitest/coverage-istanbul": "4.1.10",
- "@vitest/coverage-v8": "4.1.10",
- "@vitest/ui": "4.1.10",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
diff --git a/desktop/package.json b/desktop/package.json
index fdc8d8a09..8901516db 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -6,8 +6,10 @@
"scripts": {
"dev": "vite",
"build": "npm run check:version && npm run check:tauri && npm run check:protocol && tsc --noEmit && vite build",
+ "build:web": "npm run check:protocol && tsc --noEmit && vite build --mode web",
+ "test:web": "npm run build:web && playwright test",
"setup:sidecar": "node scripts/run-python.mjs scripts/setup-sidecar-env.py",
- "build:sidecar": "node scripts/run-python.mjs scripts/build-sidecar.py",
+ "build:sidecar": "npm run build:web && node scripts/run-python.mjs scripts/build-sidecar.py",
"audit:licenses": "node scripts/run-python.mjs scripts/audit-licenses.py --output build/licenses/dependencies.json",
"check:version": "node scripts/check-version.mjs",
"check:tauri": "node scripts/check-tauri-config.mjs",
@@ -40,6 +42,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.58.2",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
@@ -55,7 +58,7 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.64.0",
"vite": "^8.1.4",
- "vitest": "^4.1.10"
+ "vitest": "^4.1.11"
},
"overrides": {
"nanoid": "3.3.18"
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
new file mode 100644
index 000000000..085f38ace
--- /dev/null
+++ b/desktop/playwright.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ workers: 1,
+ reporter: [["list"], ["json", { outputFile: "test-results/results.json" }]],
+ timeout: process.env.DEEPCODE_WEB_LIVE === "1" ? 360000 : 90000,
+ use: {
+ actionTimeout: 10000,
+ headless: true,
+ viewport: { width: 1440, height: 1000 },
+ screenshot: "only-on-failure",
+ trace: "retain-on-failure",
+ launchOptions: process.env.DEEPCODE_CHROME_PATH
+ ? { executablePath: process.env.DEEPCODE_CHROME_PATH }
+ : {},
+ },
+});
diff --git a/desktop/scripts/build-sidecar.py b/desktop/scripts/build-sidecar.py
index e08d10e8c..2eb35c0dd 100644
--- a/desktop/scripts/build-sidecar.py
+++ b/desktop/scripts/build-sidecar.py
@@ -15,7 +15,6 @@
import sys
from pathlib import Path
-
DESKTOP_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = DESKTOP_ROOT.parent
BUILD_ROOT = DESKTOP_ROOT / "build" / "sidecar"
@@ -23,6 +22,7 @@
APP_SERVER_ROOT = DIST_ROOT / "deepcode-app-server"
SIDECAR_ENV_ROOT = BUILD_ROOT / ".venv"
BUNDLED_DATA = (
+ (REPOSITORY_ROOT / "app_server" / "web_assets", "app_server/web_assets"),
(
REPOSITORY_ROOT / "core" / "application" / "goal_prompts",
"core/application/goal_prompts",
@@ -201,6 +201,7 @@ def _verify_bundle(binary: Path) -> None:
result.get("ok") is not True
or result.get("skillCreator") is not True
or not result.get("bundledMcpPresets")
+ or result.get("webAssets") is not True
):
raise RuntimeError("packaged runtime import probe did not report success")
@@ -210,14 +211,7 @@ def _verify_bundle(binary: Path) -> None:
database = smoke_root / "state.sqlite3"
environment = dict(os.environ)
environment["DEEPCODE_HOME"] = str(home)
- process = subprocess.Popen(
- [str(binary), "--database", str(database)],
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=environment,
- )
+ environment["DEEPCODE_SESSIONS_DIR"] = str(home / "sessions")
initialize = {
"jsonrpc": "2.0",
"id": 1,
@@ -228,30 +222,99 @@ def _verify_bundle(binary: Path) -> None:
},
}
shutdown = {"jsonrpc": "2.0", "id": 2, "method": "shutdown", "params": {}}
+ process = None
try:
+ subprocess.run(
+ [
+ str(binary),
+ "--service",
+ "start",
+ "--database",
+ str(database),
+ "--port",
+ "0",
+ "--json",
+ ],
+ env=environment,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=45,
+ )
+ process = subprocess.Popen(
+ [str(binary), "--database", str(database)],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=environment,
+ )
stdout, stderr = process.communicate(
f"{json.dumps(initialize)}\n{json.dumps(shutdown)}\n",
timeout=30,
)
+ if process.returncode != 0:
+ raise RuntimeError(
+ f"packaged App Server smoke failed ({process.returncode}): "
+ f"{stderr[-2_000:]}"
+ )
+ responses = [json.loads(line) for line in stdout.splitlines() if line.strip()]
+ if (
+ len(responses) != 2
+ or responses[0].get("result", {}).get("protocolVersion") != "1.0"
+ ):
+ raise RuntimeError(
+ "packaged App Server smoke returned invalid RPC responses"
+ )
+ except subprocess.CalledProcessError as exc:
+ service_log = database.with_name(database.name + ".service") / "service.log"
+ detail = (
+ service_log.read_text(encoding="utf-8", errors="replace")
+ if service_log.exists()
+ else "No service log was created."
+ )
+ raise RuntimeError(
+ f"packaged service startup failed ({exc.returncode}):\n"
+ f"{exc.stdout[-2_000:]}\n{exc.stderr[-2_000:]}\n{detail[-4_000:]}"
+ ) from exc
except subprocess.TimeoutExpired:
- process.kill()
- stdout, stderr = process.communicate()
+ if process is not None:
+ process.kill()
+ stdout, stderr = process.communicate()
+ else:
+ stderr = "Timed out starting the packaged service"
raise RuntimeError(
f"packaged App Server smoke timed out: {stderr[-2_000:]}"
) from None
finally:
- shutil.rmtree(smoke_root, ignore_errors=True)
- if process.returncode != 0:
- raise RuntimeError(
- f"packaged App Server smoke failed ({process.returncode}): "
- f"{stderr[-2_000:]}"
- )
- responses = [json.loads(line) for line in stdout.splitlines() if line.strip()]
- if (
- len(responses) != 2
- or responses[0].get("result", {}).get("protocolVersion") != "1.0"
- ):
- raise RuntimeError("packaged App Server smoke returned invalid RPC responses")
+ failure = sys.exception()
+ try:
+ subprocess.run(
+ [
+ str(binary),
+ "--service",
+ "stop",
+ "--database",
+ str(database),
+ "--cancel-running",
+ "--timeout",
+ "3",
+ "--json",
+ ],
+ env=environment,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=35,
+ )
+ except (OSError, subprocess.SubprocessError) as exc:
+ detail = f"packaged service cleanup failed: {exc}"
+ if failure is None:
+ raise RuntimeError(detail) from exc
+ # Keep the original startup/protocol failure as the primary error.
+ failure.add_note(detail)
+ finally:
+ shutil.rmtree(smoke_root, ignore_errors=True)
if __name__ == "__main__":
diff --git a/desktop/src-tauri/binaries/.gitkeep b/desktop/src-tauri/binaries/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs
index ebeb1cbf3..d860e1e6a 100644
--- a/desktop/src-tauri/build.rs
+++ b/desktop/src-tauri/build.rs
@@ -1,6 +1,3 @@
fn main() {
- if let Ok(target) = std::env::var("TARGET") {
- println!("cargo:rustc-env=DEEPCODE_TARGET_TRIPLE={target}");
- }
tauri_build::build()
}
diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs
index c39f73f7c..48ebf24ce 100644
--- a/desktop/src-tauri/src/sidecar.rs
+++ b/desktop/src-tauri/src/sidecar.rs
@@ -225,7 +225,12 @@ impl RpcBridge {
}
pub fn request(&self, method: &str, params: Value) -> PendingResult {
- self.request_with_timeout(method, params, REQUEST_TIMEOUT)
+ let timeout = if method == "provider/test" {
+ Duration::from_secs(125)
+ } else {
+ REQUEST_TIMEOUT
+ };
+ self.request_with_timeout(method, params, timeout)
}
pub fn restart(self: &Arc) -> Result {
@@ -241,11 +246,9 @@ impl RpcBridge {
self.update_phase(SidecarPhase::Stopping, None);
let _ = self.request_with_timeout("shutdown", json!({}), SHUTDOWN_REQUEST_TIMEOUT);
- // The App Server acknowledges shutdown before its application-level
- // cleanup finishes. Give that cleanup one independent grace period:
- // coordinator quiescing, scheduler release, live Turn cancellation,
- // terminal process groups, and the lifetime lease all complete before
- // the Python process exits.
+ // The child is a native RPC attachment. Shutdown closes its connection;
+ // allow pipe and transport cleanup to finish before reaping the relay.
+ // The shared service retains the application and accepted tasks.
let deadline = Instant::now() + SHUTDOWN_EXIT_GRACE;
loop {
let exited = {
@@ -510,7 +513,7 @@ impl RpcBridge {
phase: SidecarPhase::Crashed,
message: Some(message),
launch_source: self.status().launch_source,
- server_info: None,
+ server_info: self.status().server_info,
});
}
@@ -630,22 +633,6 @@ fn resolve_launch_spec(app: &AppHandle) -> Result {
if source_bundle.is_file() {
return Ok(binary_launch(source_bundle, "source App Server bundle"));
}
-
- let target = env!("DEEPCODE_TARGET_TRIPLE");
- let source_name = if cfg!(windows) {
- format!("deepcode-app-server-{target}.exe")
- } else {
- format!("deepcode-app-server-{target}")
- };
- let source_binary = repository
- .join("desktop/src-tauri/binaries")
- .join(source_name);
- if source_binary.is_file() {
- return Ok(binary_launch(
- source_binary,
- "legacy source external binary",
- ));
- }
}
#[cfg(debug_assertions)]
diff --git a/desktop/src/App.module.css b/desktop/src/App.module.css
index aad9ca3f4..c137ea143 100644
--- a/desktop/src/App.module.css
+++ b/desktop/src/App.module.css
@@ -15,7 +15,7 @@
position: relative;
display: grid;
min-width: 0;
- height: calc(100vh - 16px);
+ height: calc(100% - 16px);
grid-template-rows: auto minmax(0, 1fr) auto;
margin: 8px 8px 8px 4px;
overflow: hidden;
@@ -123,7 +123,7 @@
.reviewPane {
position: relative;
min-width: 0;
- height: calc(100vh - 16px);
+ height: calc(100% - 16px);
margin: 8px 8px 8px 0;
overflow: hidden;
border: 1px solid var(--border-subtle);
@@ -143,7 +143,7 @@
top: 8px;
right: 8px;
width: min(430px, calc(100vw - 72px));
- height: calc(100vh - 16px);
+ height: calc(100% - 16px);
margin: 0;
box-shadow: var(--shadow-float);
}
diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx
index 58575aa0f..02bf73ca1 100644
--- a/desktop/src/App.test.tsx
+++ b/desktop/src/App.test.tsx
@@ -1,7 +1,9 @@
import {
cleanup,
+ act,
fireEvent,
render,
+ renderHook,
screen,
waitFor,
within,
@@ -28,12 +30,13 @@ import type {
WorkflowRun,
} from "./generated/app-server";
import { App } from "./App";
+import { useWorkspaceController } from "./app/useWorkspaceController";
import { __resetComposerBehaviorForTests } from "./app/composerBehavior";
import { __resetEscapeLayersForTests } from "./app/escapeLayer";
import { __setLocaleForTests } from "./app/i18n";
import type {
AnyRpcNotification,
- DesktopRuntime,
+ ClientRuntime,
DesktopUpdateInfo,
DesktopUpdateProgress,
RpcMethod,
@@ -227,7 +230,9 @@ const diagnostics: DiagnosticsSnapshot = {
],
};
-class TestRuntime implements DesktopRuntime {
+class TestRuntime implements ClientRuntime {
+ readonly notifications = new Set<(notification: AnyRpcNotification) => void>();
+ readonly statuses = new Set<(status: SidecarStatus) => void>();
readonly calls: string[] = [];
readonly requests: Array<{ method: string; params: unknown }> = [];
readonly diagnosticsExports: DiagnosticsSnapshot[] = [];
@@ -1004,12 +1009,19 @@ class TestRuntime implements DesktopRuntime {
},
} as unknown as MethodResults[M];
}
- case "event/replay":
+ case "event/replay": {
+ const { threadId, after = 0, through, limit = 500 } = params as MethodParams["event/replay"];
+ const history = this.events.filter((event) => event.threadId === threadId);
+ const headSequence = Math.min(through ?? Infinity, history.at(-1)?.sequence ?? 0);
+ const remaining = history.filter((event) => event.sequence > after && event.sequence <= headSequence);
+ const events = remaining.slice(0, limit);
return {
- events: this.events,
- nextAfter: null,
- hasMore: false,
+ events,
+ nextAfter: remaining.length > limit ? events.at(-1)!.sequence : null,
+ hasMore: remaining.length > limit,
+ headSequence,
} as MethodResults[M];
+ }
case "file/list":
return { entries: [], truncated: false } as unknown as MethodResults[M];
case "git/status":
@@ -1076,13 +1088,13 @@ class TestRuntime implements DesktopRuntime {
}
async onNotification(listener: (notification: AnyRpcNotification) => void) {
- void listener;
- return () => undefined;
+ this.notifications.add(listener);
+ return () => { this.notifications.delete(listener); };
}
async onStatus(listener: (status: SidecarStatus) => void) {
- void listener;
- return () => undefined;
+ this.statuses.add(listener);
+ return () => { this.statuses.delete(listener); };
}
async onLog(listener: (message: string) => void) {
@@ -1275,6 +1287,160 @@ const recoveryEvents: Event[] = [
},
];
+function liveDelta(sequence: number, delta: string): Event {
+ return {
+ ...recoveryEvents[1],
+ eventId: `event-${sequence}`,
+ sequence,
+ type: "item.delta",
+ payload: { delta },
+ };
+}
+
+describe("workspace event recovery", () => {
+ it("repairs skipped deltas and a dropped approval without resetting the selected item", async () => {
+ const events = [...recoveryEvents];
+ const runtime = new TestRuntime([project], [thread], events);
+ const { result } = renderHook(() => useWorkspaceController(runtime));
+ await waitFor(() => expect(result.current.state.items).toHaveLength(1));
+ act(() => result.current.selectItem("item-1"));
+ events.push(liveDelta(3, " A"), liveDelta(4, "B"));
+ act(() =>
+ runtime.notifications.forEach((receive) =>
+ receive({ jsonrpc: "2.0", method: "item.delta", params: events[3] }),
+ ),
+ );
+ await waitFor(() =>
+ expect(result.current.state.items[0].payload.text).toBe(
+ "Recovered final answer AB",
+ ),
+ );
+ expect(result.current.state.selectedItemId).toBe("item-1");
+ const approval = {
+ ...recoveryEvents[0],
+ eventId: "event-5",
+ sequence: 5,
+ type: "approval.requested",
+ payload: { approval: pendingApproval as unknown as JsonValue },
+ };
+ events.push(approval);
+ act(() =>
+ runtime.notifications.forEach((receive) =>
+ receive({
+ jsonrpc: "2.0",
+ method: "server.warning",
+ params: {
+ code: "EVENT_QUEUE_OVERFLOW",
+ dropped: 1,
+ replayRequired: true,
+ },
+ }),
+ ),
+ );
+ await waitFor(() => expect(result.current.state.approvals).toHaveLength(1));
+ expect(result.current.state.selectedItemId).toBe("item-1");
+ const replayRequests = runtime.requests.filter(
+ (request) => request.method === "event/replay",
+ );
+ expect(
+ replayRequests.map(
+ (request) => (request.params as MethodParams["event/replay"]).after,
+ ),
+ ).toEqual([0, 2, 4]);
+ });
+
+ it("holds a newer live delta until its replayed base exists", async () => {
+ const events = [...recoveryEvents];
+ const runtime = new TestRuntime([project], [thread], events);
+ const original = runtime.request.bind(runtime);
+ let resolve!: (value: MethodResults["event/replay"]) => void;
+ const first = new Promise((yes) => {
+ resolve = yes;
+ });
+ let paused = false;
+ vi.spyOn(runtime, "request").mockImplementation(async (method, params) => {
+ if (method === "event/replay" && !paused) {
+ paused = true;
+ return first as Promise;
+ }
+ return original(method, params);
+ });
+ const { result } = renderHook(() => useWorkspaceController(runtime));
+ await waitFor(() => expect(paused).toBe(true));
+ const delta = liveDelta(3, " appended once");
+ events.push(delta);
+ act(() =>
+ runtime.notifications.forEach((receive) =>
+ receive({ jsonrpc: "2.0", method: "item.delta", params: delta }),
+ ),
+ );
+ expect(result.current.state.items).toHaveLength(0);
+ await act(async () =>
+ resolve({
+ events: recoveryEvents,
+ nextAfter: null,
+ hasMore: false,
+ headSequence: 2,
+ }),
+ );
+ await waitFor(() =>
+ expect(result.current.state.items[0]?.payload.text).toBe(
+ "Recovered final answer appended once",
+ ),
+ );
+ act(() =>
+ runtime.notifications.forEach((receive) =>
+ receive({ jsonrpc: "2.0", method: "item.delta", params: delta }),
+ ),
+ );
+ expect(result.current.state.items[0].payload.text).toBe(
+ "Recovered final answer appended once",
+ );
+ });
+
+ it.each(["stopped", "starting"] as const)(
+ "replays missed events when a %s runtime becomes ready again",
+ async (phase) => {
+ const events = [...recoveryEvents];
+ const runtime = new TestRuntime([project], [thread], events);
+ const { result } = renderHook(() => useWorkspaceController(runtime));
+ await waitFor(() => expect(runtime.calls).toContain("settings/read"));
+ act(() =>
+ runtime.statuses.forEach((receive) =>
+ receive({ ...readyStatus, phase }),
+ ),
+ );
+ events.push(liveDelta(3, " after reconnect"));
+ act(() => runtime.statuses.forEach((receive) => receive(readyStatus)));
+ await waitFor(() =>
+ expect(result.current.state.items[0]?.payload.text).toBe(
+ "Recovered final answer after reconnect",
+ ),
+ );
+ expect(
+ runtime.calls.filter((method) => method === "project/list"),
+ ).toHaveLength(2);
+ },
+ );
+
+ it("cleans up a notification subscription that resolves after unmount", async () => {
+ const runtime = new TestRuntime();
+ let resolve!: (cleanup: () => void) => void;
+ vi.spyOn(runtime, "onNotification").mockImplementation(
+ () =>
+ new Promise((yes) => {
+ resolve = yes;
+ }),
+ );
+ const cleanup = vi.fn();
+ const { unmount } = renderHook(() => useWorkspaceController(runtime));
+ unmount();
+ await act(async () => resolve(cleanup));
+ expect(cleanup).toHaveBeenCalledOnce();
+ expect(runtime.calls).toEqual([]);
+ });
+});
+
const presentationItems: Item[] = [
{
id: "item-presentation-user",
@@ -2429,7 +2595,7 @@ describe("desktop command center", () => {
const discoverParams = runtime.requests.find(
(request) => request.method === "provider/discover",
)?.params as MethodParams["provider/discover"];
- expect(discoverParams.connectionId).toBe("openai");
+ expect(discoverParams.connection).toMatchObject({ id: "openai", protocol: "auto", auth: "api_key" });
fireEvent.click(screen.getByRole("button", { name: "Save and check" }));
await waitFor(() => {
@@ -2479,7 +2645,7 @@ describe("desktop command center", () => {
model: "gpt-5",
});
});
- expect(await screen.findByText("Ready for agent work")).toBeTruthy();
+ expect(await screen.findByText("Model request verified")).toBeTruthy();
const methods = runtime.requests.map((request) => request.method);
expect(methods.indexOf("settings/update")).toBeLessThan(
methods.indexOf("provider/test"),
diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx
index 700f4d251..637f4acc6 100644
--- a/desktop/src/App.tsx
+++ b/desktop/src/App.tsx
@@ -15,8 +15,7 @@ import {
import { DesktopSidebar } from "./features/navigation/DesktopSidebar";
import { ThreadHeader } from "./features/thread/ThreadHeader";
import { useTranscriptMode } from "./features/thread/transcriptMode";
-import type { DesktopRuntime } from "./rpc/contracts";
-import { tauriRuntime } from "./rpc/tauriRuntime";
+import type { ClientRuntime } from "./rpc/contracts";
import { initI18n } from "./app/i18n";
import styles from "./App.module.css";
@@ -67,7 +66,7 @@ function LoadingSurface({
);
}
-export function App({ runtime = tauriRuntime }: { runtime?: DesktopRuntime }) {
+export function App({ runtime }: { runtime: ClientRuntime }) {
const controller = useWorkspaceController(runtime);
// Mounted for its effect: paints saved appearance preferences at startup.
useAppearance();
@@ -190,6 +189,7 @@ export function App({ runtime = tauriRuntime }: { runtime?: DesktopRuntime }) {
(null);
+ const [path, setPath] = useState("");
+ const [notice, setNotice] = useState(null);
+ const [runtime] = useState(
+ () =>
+ new BrowserRuntime({
+ buildId: __WEB_BUILD_ID__,
+ chooseDirectory: () =>
+ new Promise((resolve) => {
+ setPath("");
+ setPicker({ resolve });
+ }),
+ }),
+ );
+ useEffect(() => {
+ const dispose = () => runtime.dispose();
+ // Opening a fresh access link in this tab only changes its fragment.
+ // Reload so the new document exchanges the ticket before connecting.
+ const openAccessLink = () => {
+ if (new URLSearchParams(location.hash.slice(1)).has("ticket"))
+ location.reload();
+ };
+ window.addEventListener("pagehide", dispose);
+ window.addEventListener("hashchange", openAccessLink);
+ return () => {
+ window.removeEventListener("pagehide", dispose);
+ window.removeEventListener("hashchange", openAccessLink);
+ dispose();
+ };
+ }, [runtime]);
+ const closePicker = (value: string | null) => {
+ picker?.resolve(value);
+ setPicker(null);
+ };
+ return (
+
+
+ DeepCode · Local service
+ {notice}
+
+ void runtime
+ .logout()
+ .then(() =>
+ setNotice("Signed out · run deepcode web for a fresh link"),
+ )
+ .catch((error) => setNotice(String(error)))
+ }
+ >
+ Sign out
+
+
+
+ {picker && (
+
+ )}
+
+ );
+}
diff --git a/desktop/src/app/i18n.ts b/desktop/src/app/i18n.ts
index a3e6efe0d..6e46df3da 100644
--- a/desktop/src/app/i18n.ts
+++ b/desktop/src/app/i18n.ts
@@ -22,6 +22,53 @@ export const LOCALES = [
export type Locale = (typeof LOCALES)[number]["value"];
const ZH_CN: Record = {
+ "provider.signInAuth": "使用 OpenRouter 登录",
+ "provider.account": "账户",
+ "provider.loginExplanation": "OpenRouter 登录会返回由用户管理的 API key,不提供刷新令牌。请在运行 DeepCode 的机器上登录。",
+ "provider.cancelLogin": "取消登录",
+ "provider.disconnectConfirm": "在本机断开此账户?之后的模型请求将停止。远端密钥请在 OpenRouter 设置页撤销。",
+ "provider.disconnect": "断开账户",
+ "provider.manageKeys": "管理远端密钥",
+ "provider.openLogin": "打开登录页面",
+ "provider.login.starting": "正在启动登录",
+ "provider.login.pending": "等待授权",
+ "provider.login.exchanging": "正在完成授权",
+ "provider.login.authenticated": "已登录",
+ "provider.login.cancelled": "已取消",
+ "provider.login.expired": "登录已过期",
+ "provider.login.failed": "登录失败",
+
+ "provider.protocol": "API 协议",
+ "provider.auto": "自动 · 保持现有路由",
+ "provider.auth": "认证方式",
+ "provider.apiKey": "API 密钥",
+ "provider.noAuth": "无需认证",
+ "provider.compat": "协议兼容性",
+ "provider.compatExplicit": "请先选择明确的 API 协议,再设置兼容选项。",
+ "provider.inherit": "继承默认值",
+ "provider.yes": "是",
+ "provider.no": "否",
+ "provider.resetCompat": "清除兼容选项",
+ "provider.verifyDraft": "验证当前表单",
+ "provider.verifyModel": "待验证模型",
+ "provider.probeBudget": "使用当前表单,不保存配置。Agent 验证最多调用模型 3 次,总预算 90 秒,仅使用本地验证工具;Provider 的推理模式可能增加 token 用量。",
+ "provider.quick": "快速测试",
+ "provider.agentTest": "验证 Agent 兼容性",
+ "provider.testing": "正在验证…",
+ "provider.probeStale": "设置已变化,请重新验证当前配置。",
+ "provider.capabilities": "模型能力",
+ "provider.inputModalities": "输入类型",
+ "provider.textOnly": "仅文本",
+ "provider.textImage": "文本与图像",
+ "provider.toolCalling": "工具调用",
+ "provider.compat.tokenLimitField": "Token 上限字段",
+ "provider.compat.temperature": "发送 temperature",
+ "provider.compat.systemRole": "指令消息角色",
+ "provider.compat.reasoningField": "推理参数字段",
+ "provider.compat.reasoningContent": "推理历史回传",
+ "provider.compat.toolMessageName": "发送工具结果名称",
+ "provider.compat.parallelToolCalls": "并行工具调用",
+
// Settings dialog shell
"settings.title": "设置",
"settings.section.general": "通用",
@@ -159,6 +206,14 @@ const ZH_CN: Record = {
// Runtime notice
"runtime.offline": "本地应用服务器不可用。",
"runtime.restart": "重启服务",
+ "runtime.reconnect": "重新连接",
+ "runtime.browserAuthRequired": "需要授权浏览器访问",
+ "runtime.browserAuthHelp": "请在终端运行 deepcode web,打开新生成的浏览器访问链接。无需 DeepCode 账号。",
+ "service.title": "后台服务",
+ "service.stop": "停止后台服务",
+ "service.detach": "关闭 Desktop 只断开当前窗口;任务和定时工作会继续在共享后台运行。",
+ "service.activity": "{{phase}} · {{active}} 个执行中任务 · {{queued}} 个排队任务 · {{terminals}} 个终端",
+ "service.stopConfirm": "停止共享后台?当前有 {{active}} 个执行中任务、{{queued}} 个排队任务、{{terminals}} 个终端。最多等待 10 秒;仍有活动工作时会保留服务运行。",
// Goal rail
"goal.setGoal": "设定目标",
"goal.setGoalHint": "在普通 Turn 之间保持一个持久的目标",
diff --git a/desktop/src/app/interactiveTurnRouter.test.ts b/desktop/src/app/interactiveTurnRouter.test.ts
index 5eb7bcd86..0ff900269 100644
--- a/desktop/src/app/interactiveTurnRouter.test.ts
+++ b/desktop/src/app/interactiveTurnRouter.test.ts
@@ -7,7 +7,7 @@ import type {
} from "../generated/app-server";
import type {
BridgeError,
- DesktopRuntime,
+ ClientRuntime,
RpcMethod,
} from "../rpc/contracts";
import {
@@ -24,8 +24,8 @@ type Step =
| { method: RpcMethod; result: unknown }
| { method: RpcMethod; error: BridgeError };
-function scriptedRuntime(steps: Step[], calls: RecordedCall[]): DesktopRuntime {
- const transport: Pick = {
+function scriptedRuntime(steps: Step[], calls: RecordedCall[]): ClientRuntime {
+ const transport: Pick = {
async request(
method: M,
params: MethodParams[M],
@@ -38,7 +38,7 @@ function scriptedRuntime(steps: Step[], calls: RecordedCall[]): DesktopRuntime {
return step.result as MethodResults[M];
},
};
- return transport as DesktopRuntime;
+ return transport as ClientRuntime;
}
function turn(id: string, status: Turn["status"], ordinal = 1): Turn {
diff --git a/desktop/src/app/interactiveTurnRouter.ts b/desktop/src/app/interactiveTurnRouter.ts
index 3c03c8a48..101227c57 100644
--- a/desktop/src/app/interactiveTurnRouter.ts
+++ b/desktop/src/app/interactiveTurnRouter.ts
@@ -3,7 +3,7 @@ import type {
Turn,
TurnStartParams,
} from "../generated/app-server";
-import type { BridgeError, DesktopRuntime } from "../rpc/contracts";
+import type { BridgeError, ClientRuntime } from "../rpc/contracts";
export type InteractiveDelivery = "started" | "steered" | "queued";
@@ -43,7 +43,7 @@ export interface InteractiveTurnInput {
* the user's input.
*/
export async function sendInteractiveTurn(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
input: InteractiveTurnInput,
): Promise {
const messageId = input.messageId ?? `desktop-${crypto.randomUUID()}`;
@@ -86,7 +86,7 @@ export function latestExecutingTurn(
}
async function start(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
input: InteractiveTurnInput,
messageId: string,
): Promise {
@@ -108,7 +108,7 @@ async function start(
}
async function startOrSteerOnce(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
input: InteractiveTurnInput,
messageId: string,
): Promise {
@@ -126,7 +126,7 @@ async function startOrSteerOnce(
}
async function enqueue(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
input: InteractiveTurnInput,
messageId: string,
): Promise {
@@ -148,7 +148,7 @@ async function enqueue(
}
async function steerOrEnqueue(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
input: InteractiveTurnInput,
expectedTurnId: string,
messageId: string,
@@ -164,7 +164,7 @@ async function steerOrEnqueue(
}
async function steer(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
input: InteractiveTurnInput,
expectedTurnId: string,
messageId: string,
diff --git a/desktop/src/app/replayThreadHistory.test.ts b/desktop/src/app/replayThreadHistory.test.ts
index 30a889cb6..005069c96 100644
--- a/desktop/src/app/replayThreadHistory.test.ts
+++ b/desktop/src/app/replayThreadHistory.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import type {
Event,
@@ -7,6 +7,7 @@ import type {
} from "../generated/app-server";
import type { RpcMethod, RpcTransport } from "../rpc/contracts";
import { replayThreadHistory } from "./replayThreadHistory";
+import { ThreadEventStream } from "./threadEventStream";
function event(sequence: number): Event {
return {
@@ -46,6 +47,57 @@ class ReplayRuntime implements RpcTransport {
}
describe("replayThreadHistory", () => {
+ it("pins the first page's head while later events continue to arrive", async () => {
+ const requests: MethodParams["event/replay"][] = [];
+ const runtime = new ReplayRuntime(async (params) => {
+ requests.push(params);
+ return params.after === 0
+ ? { events: [event(1)], nextAfter: 1, hasMore: true, headSequence: 2 }
+ : {
+ events: [event(2)],
+ nextAfter: null,
+ hasMore: false,
+ headSequence: 2,
+ };
+ });
+ const received: number[] = [];
+ expect(
+ await replayThreadHistory(runtime, "thread-1", (value) =>
+ received.push(value.sequence),
+ ),
+ ).toBe(2);
+ expect(requests[0].through).toBeUndefined();
+ expect(requests[1].through).toBe(2);
+ expect(received).toEqual([1, 2]);
+ });
+
+ it.each([
+ { events: [event(1), event(3)], headSequence: 3 },
+ { events: [{ ...event(1), threadId: "another" }], headSequence: 1 },
+ { events: [event(1)], headSequence: 2 },
+ { events: [event(1)], headSequence: 0 },
+ ])("rejects missing or mismatched replay evidence", async (page) => {
+ const runtime = new ReplayRuntime(async () => ({
+ ...page,
+ hasMore: false,
+ nextAfter: null,
+ }));
+ await expect(
+ replayThreadHistory(runtime, "thread-1", () => undefined),
+ ).rejects.toThrow("event/replay");
+ });
+
+ it("does not silently accept a cursor ahead of replaced history", async () => {
+ const runtime = new ReplayRuntime(async () => ({
+ events: [],
+ hasMore: false,
+ nextAfter: null,
+ headSequence: 1,
+ }));
+ await expect(
+ replayThreadHistory(runtime, "thread-1", () => undefined, { after: 5 }),
+ ).rejects.toThrow("history changed");
+ });
it("continues from the server cursor even when a byte-bounded page is short", async () => {
const runtime = new ReplayRuntime(async ({ after }) =>
after === 0
@@ -96,3 +148,172 @@ describe("replayThreadHistory", () => {
).rejects.toThrow("event/replay did not advance its cursor");
});
});
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+
+function page(...sequences: number[]): MethodResults["event/replay"] {
+ return { events: sequences.map(event), nextAfter: null, hasMore: false };
+}
+
+describe("ThreadEventStream", () => {
+ it("orders replay before overlapping live deltas and drops duplicates", async () => {
+ const first = deferred();
+ const runtime = new ReplayRuntime(async ({ after }) =>
+ after === 0 ? first.promise : page(3, 4),
+ );
+ const accepted: number[] = [];
+ const stream = new ThreadEventStream(
+ runtime,
+ "thread-1",
+ (value) => accepted.push(value.sequence),
+ vi.fn(),
+ );
+ const recovering = stream.recover();
+ stream.receive(event(4));
+ stream.receive(event(3));
+ stream.receive(event(2));
+ expect(accepted).toEqual([]);
+ first.resolve(page(1, 2));
+ await recovering;
+ stream.receive(event(3));
+ stream.receive(event(5));
+ expect(accepted).toEqual([1, 2, 3, 4, 5]);
+ expect(runtime.afters).toEqual([0, 2]);
+ });
+
+ it("repairs an out-of-order live gap from its last contiguous cursor", async () => {
+ const runtime = new ReplayRuntime(async () => page(2, 3));
+ const accepted: number[] = [];
+ const stream = new ThreadEventStream(
+ runtime,
+ "thread-1",
+ (value) => accepted.push(value.sequence),
+ vi.fn(),
+ );
+ stream.receive(event(1));
+ stream.receive(event(3));
+ await vi.waitFor(() => expect(accepted).toEqual([1, 2, 3]));
+ stream.receive(event(2));
+ expect(accepted).toEqual([1, 2, 3]);
+ expect(runtime.afters).toEqual([1]);
+ });
+
+ it("coalesces overflow warnings and recovers a missing tail during replay", async () => {
+ const first = deferred();
+ const runtime = new ReplayRuntime(async ({ after }) =>
+ after === 0 ? first.promise : page(2),
+ );
+ const accepted: number[] = [];
+ const stream = new ThreadEventStream(
+ runtime,
+ "thread-1",
+ (value) => accepted.push(value.sequence),
+ vi.fn(),
+ );
+ const initial = stream.recover();
+ for (let index = 0; index < 100; index++)
+ expect(stream.recover()).toBe(initial);
+ expect(runtime.afters).toEqual([0]);
+ first.resolve(page(1));
+ await initial;
+ expect(accepted).toEqual([1, 2]);
+ expect(runtime.afters).toEqual([0, 1]);
+ });
+
+ it("recovers a large live burst with one in-flight replay instead of retaining live payloads", async () => {
+ const first = deferred();
+ const runtime = new ReplayRuntime(async ({ after = 0, limit = 1000 }) => {
+ if (!after) return first.promise;
+ const last = Math.min(after + limit, 3000);
+ return {
+ events: Array.from({ length: last - after }, (_, index) =>
+ event(after + index + 1),
+ ),
+ headSequence: 3000,
+ nextAfter: last < 3000 ? last : null,
+ hasMore: last < 3000,
+ };
+ });
+ const accepted: number[] = [];
+ const stream = new ThreadEventStream(
+ runtime,
+ "thread-1",
+ (value) => accepted.push(value.sequence),
+ vi.fn(),
+ );
+ const done = stream.recover();
+ for (let sequence = 3000; sequence > 0; sequence--)
+ stream.receive(event(sequence));
+ expect(runtime.afters).toEqual([0]);
+ first.resolve(page(1));
+ await done;
+ expect(accepted).toEqual(
+ Array.from({ length: 3000 }, (_, index) => index + 1),
+ );
+ expect(runtime.afters).toEqual([0, 1, 1001, 2001]);
+ });
+
+ it.each([false, true])(
+ "ignores late results or errors after selection changes (%s)",
+ async (fails) => {
+ const pending = deferred();
+ const runtime = new ReplayRuntime(async () => pending.promise);
+ const accept = vi.fn();
+ const onError = vi.fn();
+ const stream = new ThreadEventStream(
+ runtime,
+ "thread-1",
+ accept,
+ onError,
+ );
+ const done = stream.recover();
+ stream.stop();
+ stream.receive(event(1));
+ if (fails) pending.reject(new Error("connection closed"));
+ else pending.resolve(page(1));
+ await done;
+ expect(accept).not.toHaveBeenCalled();
+ expect(onError).not.toHaveBeenCalled();
+ },
+ );
+
+ it("retains only applied progress after failure and can repair on a later warning", async () => {
+ let calls = 0;
+ const runtime = new ReplayRuntime(async () => {
+ calls++;
+ if (calls === 1) return { ...page(1), nextAfter: 1, hasMore: true };
+ if (calls === 2) throw new Error("connection closed");
+ return page(2, 3);
+ });
+ const accepted: number[] = [];
+ const stream = new ThreadEventStream(
+ runtime,
+ "thread-1",
+ (value) => accepted.push(value.sequence),
+ vi.fn(),
+ );
+ await expect(stream.recover()).rejects.toThrow("connection closed");
+ await stream.recover();
+ expect(accepted).toEqual([1, 2, 3]);
+ expect(runtime.afters).toEqual([0, 1, 1]);
+ });
+
+ it("surfaces irrecoverable history instead of skipping a gap or spinning", async () => {
+ const runtime = new ReplayRuntime(async () => page());
+ const accept = vi.fn();
+ const onError = vi.fn();
+ const stream = new ThreadEventStream(runtime, "thread-1", accept, onError);
+ stream.receive(event(3));
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce());
+ expect(runtime.afters).toEqual([0]);
+ expect(accept).not.toHaveBeenCalled();
+ });
+});
diff --git a/desktop/src/app/replayThreadHistory.ts b/desktop/src/app/replayThreadHistory.ts
index 9c6285721..17be83140 100644
--- a/desktop/src/app/replayThreadHistory.ts
+++ b/desktop/src/app/replayThreadHistory.ts
@@ -15,19 +15,24 @@ export async function replayThreadHistory(
runtime: Pick,
threadId: string,
accept: (event: Event) => void,
-): Promise {
- let after = 0;
+ options: { after?: number; isCurrent?: () => boolean } = {},
+): Promise {
+ let after = options.after ?? 0;
+ let through: number | undefined;
let limit = DEFAULT_REPLAY_LIMIT;
+ const isCurrent = options.isCurrent ?? (() => true);
- for (;;) {
+ while (isCurrent()) {
let result: MethodResults["event/replay"];
try {
result = await runtime.request("event/replay", {
threadId,
after,
limit,
+ ...(through !== undefined ? { through } : {}),
});
} catch (error) {
+ if (!isCurrent()) return after;
if (errorCode(error) === "RESPONSE_TOO_LARGE" && limit > 1) {
limit = Math.max(1, Math.floor(limit / 2));
continue;
@@ -35,8 +40,31 @@ export async function replayThreadHistory(
throw error;
}
+ if (!isCurrent()) return after;
+ if (result.headSequence !== undefined) {
+ if (
+ !Number.isSafeInteger(result.headSequence) ||
+ result.headSequence < after ||
+ (through !== undefined && through !== result.headSequence)
+ ) {
+ throw new Error("event/replay history changed; reload the Thread");
+ }
+ through = result.headSequence;
+ }
+
for (const event of result.events) {
+ if (!isCurrent()) return after;
+ if (
+ event.threadId !== threadId ||
+ event.sequence !== after + 1 ||
+ (through !== undefined && event.sequence > through)
+ ) {
+ throw new Error(
+ "event/replay returned a non-contiguous Thread history",
+ );
+ }
accept(event);
+ after = event.sequence;
}
const compatible = result as MethodResults["event/replay"] & {
@@ -48,14 +76,17 @@ export async function replayThreadHistory(
? compatible.hasMore
: result.events.length === limit;
if (!hasMore) {
- return;
+ if (through !== undefined && after !== through) {
+ throw new Error("event/replay ended before its captured head");
+ }
+ return after;
}
const last = result.events.at(-1);
const nextAfter = compatible.nextAfter ?? last?.sequence ?? null;
- if (nextAfter === null || nextAfter <= after) {
+ if (nextAfter === null || !result.events.length || nextAfter !== after) {
throw new Error("event/replay did not advance its cursor");
}
- after = nextAfter;
}
+ return after;
}
diff --git a/desktop/src/app/threadEventStream.ts b/desktop/src/app/threadEventStream.ts
new file mode 100644
index 000000000..4d99440b5
--- /dev/null
+++ b/desktop/src/app/threadEventStream.ts
@@ -0,0 +1,78 @@
+import type { Event } from "../generated/app-server";
+import type { RpcTransport } from "../rpc/contracts";
+import { replayThreadHistory } from "./replayThreadHistory";
+
+/** One selected Thread's contiguous cursor, shared by replay and live delivery.
+ * Gapped live payloads are recovered from the durable log instead of retained
+ * in an unbounded client queue. Stopping the stream fences late RPC results.
+ */
+export class ThreadEventStream {
+ private cursor = 0;
+ private observedHead = 0;
+ private revision = 0;
+ private active = true;
+ private pending: Promise | null = null;
+
+ constructor(
+ private readonly runtime: RpcTransport,
+ readonly threadId: string,
+ private readonly accept: (event: Event) => void,
+ private readonly onError: (error: unknown) => void,
+ ) {}
+
+ stop(): void {
+ this.active = false;
+ }
+
+ receive(event: Event): void {
+ if (
+ !this.active ||
+ event.threadId !== this.threadId ||
+ event.sequence <= this.cursor
+ )
+ return;
+ this.observedHead = Math.max(this.observedHead, event.sequence);
+ if (!this.pending && event.sequence === this.cursor + 1) {
+ this.accept(event);
+ this.cursor = event.sequence;
+ } else if (!this.pending) {
+ void this.recover().catch(this.onError);
+ }
+ }
+
+ /** Also used for an overflow warning, including a lost final notification. */
+ recover(): Promise {
+ if (!this.active) return Promise.resolve();
+ this.revision += 1;
+ if (!this.pending) this.pending = this.replay();
+ return this.pending;
+ }
+
+ private async replay(): Promise {
+ try {
+ while (this.active) {
+ const revision = this.revision;
+ const before = this.cursor;
+ await replayThreadHistory(
+ this.runtime,
+ this.threadId,
+ (event) => {
+ this.accept(event);
+ this.cursor = event.sequence;
+ },
+ { after: this.cursor, isCurrent: () => this.active },
+ );
+ if (!this.active) return;
+ if (this.observedHead > this.cursor && before === this.cursor) {
+ throw new Error(
+ "event/replay cannot recover an observed event; reload the Thread",
+ );
+ }
+ if (revision === this.revision && this.observedHead <= this.cursor)
+ return;
+ }
+ } finally {
+ this.pending = null;
+ }
+ }
+}
diff --git a/desktop/src/app/useWorkspaceController.ts b/desktop/src/app/useWorkspaceController.ts
index 203eb2b97..d0d8fc3c8 100644
--- a/desktop/src/app/useWorkspaceController.ts
+++ b/desktop/src/app/useWorkspaceController.ts
@@ -17,7 +17,7 @@ import { confirmAction } from "../platform/confirmAction";
import type {
AnyRpcNotification,
BridgeError,
- DesktopRuntime,
+ ClientRuntime,
SidecarStatus,
} from "../rpc/contracts";
import {
@@ -25,7 +25,7 @@ import {
sendInteractiveTurn,
type InteractiveDelivery,
} from "./interactiveTurnRouter";
-import { replayThreadHistory } from "./replayThreadHistory";
+import { ThreadEventStream } from "./threadEventStream";
import {
initialWorkspaceState,
workspaceReducer,
@@ -57,7 +57,8 @@ function isEvent(value: unknown): value is Event {
typeof value === "object" &&
value !== null &&
"sequence" in value &&
- typeof (value as { sequence?: unknown }).sequence === "number"
+ Number.isSafeInteger((value as { sequence?: unknown }).sequence) &&
+ (value as { sequence: number }).sequence > 0
);
}
@@ -133,10 +134,11 @@ export interface WorkspaceController {
dismissError(): void;
}
-export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceController {
+export function useWorkspaceController(runtime: ClientRuntime): WorkspaceController {
const [state, dispatch] = useReducer(workspaceReducer, initialWorkspaceState);
const selectedThreadRef = useRef(null);
const loadedRuntimeRef = useRef(false);
+ const eventStreamRef = useRef(null);
const reportError = useCallback((error: unknown) => {
dispatch({ type: "error", error: normalizeError(error) });
@@ -144,12 +146,23 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
const replayThread = useCallback(
async (threadId: string) => {
+ if (selectedThreadRef.current !== threadId) return;
+ eventStreamRef.current?.stop();
dispatch({ type: "trace-reset" });
- await replayThreadHistory(runtime, threadId, (event) => {
- dispatch({ type: "event", event });
- });
+ const stream = new ThreadEventStream(
+ runtime,
+ threadId,
+ (event) => {
+ if (selectedThreadRef.current === threadId) {
+ dispatch({ type: "event", event });
+ }
+ },
+ reportError,
+ );
+ eventStreamRef.current = stream;
+ await stream.recover();
},
- [runtime],
+ [reportError, runtime],
);
const loadSettings = useCallback(
@@ -165,6 +178,7 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
const loadGoal = useCallback(
async (threadId: string) => {
const result = await runtime.request("thread/goal/get", { threadId });
+ if (selectedThreadRef.current !== threadId) return;
dispatch({
type: "goal",
goal: result.goal,
@@ -199,6 +213,7 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
localStorage.setItem(PROJECT_KEY, selectedThread.projectId);
}
selectedThreadRef.current = selected;
+ if (!selected) eventStreamRef.current?.stop();
if (selected) {
localStorage.setItem(THREAD_KEY, selected);
const resumed = await runtime.request("thread/resume", {
@@ -253,48 +268,63 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
dispatch({ type: "runtime", status });
if (status.phase === "ready") {
void loadProjects();
- } else if (status.phase === "crashed" || status.phase === "stopped") {
+ } else {
loadedRuntimeRef.current = false;
+ eventStreamRef.current?.stop();
}
};
const acceptNotification = (notification: AnyRpcNotification) => {
if (disposed) return;
if (notification.method === "server.warning") {
- const threadId = selectedThreadRef.current;
- if (threadId) void replayThread(threadId).catch(reportError);
+ if (notification.params.replayRequired === true) {
+ void eventStreamRef.current?.recover().catch(reportError);
+ }
return;
}
if (isEvent(notification.params)) {
- if (notification.method === "thread.updated") {
- dispatch({ type: "event", event: notification.params });
- return;
- }
const threadId = selectedThreadRef.current;
if (threadId === notification.params.threadId) {
+ if (eventStreamRef.current?.threadId === threadId) {
+ eventStreamRef.current.receive(notification.params);
+ }
+ } else if (notification.method === "thread.updated") {
dispatch({ type: "event", event: notification.params });
}
}
};
void (async () => {
+ const register = async (subscription: Promise<() => void>) => {
+ const cleanup = await subscription;
+ if (disposed) cleanup();
+ else cleanups.push(cleanup);
+ };
try {
- cleanups.push(await runtime.onStatus(acceptStatus));
- cleanups.push(await runtime.onNotification(acceptNotification));
- cleanups.push(
- await runtime.onLog((message) => dispatch({ type: "log", message })),
+ // Install live delivery before status can trigger the first replay.
+ await register(runtime.onNotification(acceptNotification));
+ if (disposed) return;
+ await register(runtime.onStatus(acceptStatus));
+ if (disposed) return;
+ await register(
+ runtime.onLog((message) => {
+ if (!disposed) dispatch({ type: "log", message });
+ }),
);
+ if (disposed) return;
acceptStatus(await runtime.status());
} catch (error) {
- reportError(error);
+ if (!disposed) reportError(error);
}
})();
return () => {
disposed = true;
+ eventStreamRef.current?.stop();
+ loadedRuntimeRef.current = false;
for (const cleanup of cleanups) cleanup();
};
- }, [loadProjects, replayThread, reportError, runtime]);
+ }, [loadProjects, reportError, runtime]);
const withBusy = useCallback(
async (
@@ -326,6 +356,7 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
dispatch({ type: "project-upsert", project: result.project });
dispatch({ type: "select-project", projectId: result.project.id });
selectedThreadRef.current = null;
+ eventStreamRef.current?.stop();
localStorage.setItem(PROJECT_KEY, result.project.id);
await loadThreads(result.project.id);
await loadSettings(result.project.id);
@@ -338,6 +369,7 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
withBusy(async () => {
dispatch({ type: "select-project", projectId });
selectedThreadRef.current = null;
+ eventStreamRef.current?.stop();
localStorage.setItem(PROJECT_KEY, projectId);
await loadThreads(projectId, localStorage.getItem(THREAD_KEY));
await loadSettings(projectId);
@@ -376,9 +408,10 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
dispatch({ type: "select-thread", threadId: result.thread.id });
selectedThreadRef.current = result.thread.id;
localStorage.setItem(THREAD_KEY, result.thread.id);
+ await replayThread(result.thread.id);
return result.thread;
}),
- [runtime, selectedProject, withBusy],
+ [replayThread, runtime, selectedProject, withBusy],
);
const forkThread = useCallback(
@@ -396,8 +429,9 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
dispatch({ type: "select-thread", threadId: isolated.thread.id });
selectedThreadRef.current = isolated.thread.id;
localStorage.setItem(THREAD_KEY, isolated.thread.id);
+ await replayThread(isolated.thread.id);
}),
- [runtime, selectedThread, withBusy],
+ [replayThread, runtime, selectedThread, withBusy],
);
const activateThread = useCallback(
@@ -452,6 +486,7 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
if (!wasSelected) return;
selectedThreadRef.current = null;
+ eventStreamRef.current?.stop();
localStorage.removeItem(THREAD_KEY);
const replacement =
remaining
@@ -778,9 +813,9 @@ export function useWorkspaceController(runtime: DesktopRuntime): WorkspaceContro
[runtime, selectedThread, withBusy],
);
- const pickWorkflowFile = useCallback(() => runtime.pickFile(), [runtime]);
+ const pickWorkflowFile = useCallback(() => runtime.pickFile(selectedThreadRef.current ?? undefined), [runtime]);
const pickContextFiles = useCallback(
- () => runtime.pickContextFiles(),
+ () => runtime.pickContextFiles(selectedThreadRef.current ?? undefined),
[runtime],
);
diff --git a/desktop/src/app/workspaceState.test.ts b/desktop/src/app/workspaceState.test.ts
index 14e1b0e8b..d3f1851ee 100644
--- a/desktop/src/app/workspaceState.test.ts
+++ b/desktop/src/app/workspaceState.test.ts
@@ -88,7 +88,7 @@ describe("workspace event projection", () => {
expect(replayed.items[0].status).toBe("completed");
expect(replayed.items[0].payload.text).toBe("final");
- expect(replayed.lastSequence).toBe(20);
+ expect(replayed.entitySequences["item:item-1"]).toBe(20);
});
it("projects Goal outcome from the same versioned event", () => {
@@ -325,7 +325,7 @@ describe("workspace event projection", () => {
});
expect(projected.workflows).toEqual([workflow]);
- expect(projected.lastSequence).toBe(21);
+ expect(projected.entitySequences[`workflow:${workflow.id}`]).toBe(21);
});
it("replays structured Turn plans and ignores stale updates", () => {
@@ -381,7 +381,7 @@ describe("workspace event projection", () => {
],
updatedAt: "2026-07-16T00:00:02Z",
});
- expect(stale.lastSequence).toBe(22);
+ expect(stale.entitySequences[`plan:${turn.id}`]).toBe(22);
});
it("keeps the global Session index when project context changes", () => {
@@ -416,7 +416,7 @@ describe("workspace event projection", () => {
updatedAt: "2026-07-16T00:00:00Z",
},
},
- lastSequence: 4,
+ entitySequences: { "item:item-1": 4 },
},
{ type: "thread-remove", threadId: thread.id },
);
@@ -426,6 +426,6 @@ describe("workspace event projection", () => {
expect(removed.turns).toEqual([]);
expect(removed.items).toEqual([]);
expect(removed.plansByTurnId).toEqual({});
- expect(removed.lastSequence).toBe(0);
+ expect(removed.entitySequences).toEqual({});
});
});
diff --git a/desktop/src/app/workspaceState.ts b/desktop/src/app/workspaceState.ts
index 7b96ecad5..b48381e7c 100644
--- a/desktop/src/app/workspaceState.ts
+++ b/desktop/src/app/workspaceState.ts
@@ -38,7 +38,6 @@ export interface WorkspaceState {
plansByTurnId: Record;
goal: Goal | null;
goalOutcome: GoalOutcome | null;
- lastSequence: number;
entitySequences: Record;
selectedItemId: string | null;
busy: boolean;
@@ -89,7 +88,6 @@ export const initialWorkspaceState: WorkspaceState = {
plansByTurnId: {},
goal: null,
goalOutcome: null,
- lastSequence: 0,
entitySequences: {},
selectedItemId: null,
busy: false,
@@ -154,24 +152,15 @@ function applyItemDelta(state: WorkspaceState, event: Event): WorkspaceState {
const itemId = event.itemId;
const delta = event.payload.delta;
if (!itemId || typeof delta !== "string") {
- return {
- ...state,
- lastSequence: Math.max(state.lastSequence, event.sequence),
- };
+ return state;
}
const key = `item:${itemId}`;
if ((state.entitySequences[key] ?? 0) >= event.sequence) {
- return {
- ...state,
- lastSequence: Math.max(state.lastSequence, event.sequence),
- };
+ return state;
}
const index = state.items.findIndex((candidate) => candidate.id === itemId);
if (index === -1) {
- return {
- ...state,
- lastSequence: Math.max(state.lastSequence, event.sequence),
- };
+ return state;
}
const current = state.items[index];
@@ -183,7 +172,6 @@ function applyItemDelta(state: WorkspaceState, event: Event): WorkspaceState {
if (current.kind === "reasoning_summary" && !isReasoningDelta) {
return {
...state,
- lastSequence: Math.max(state.lastSequence, event.sequence),
entitySequences: {
...state.entitySequences,
[key]: event.sequence,
@@ -218,7 +206,6 @@ function applyItemDelta(state: WorkspaceState, event: Event): WorkspaceState {
return {
...state,
items,
- lastSequence: Math.max(state.lastSequence, event.sequence),
entitySequences: {
...state.entitySequences,
[key]: event.sequence,
@@ -234,10 +221,7 @@ function applyDomainEvent(state: WorkspaceState, event: Event): WorkspaceState {
const plan = eventPlan(event);
const key = event.turnId ? `plan:${event.turnId}` : null;
if (!plan || !key || (state.entitySequences[key] ?? 0) >= event.sequence) {
- return {
- ...state,
- lastSequence: Math.max(state.lastSequence, event.sequence),
- };
+ return state;
}
return {
...state,
@@ -245,7 +229,6 @@ function applyDomainEvent(state: WorkspaceState, event: Event): WorkspaceState {
...state.plansByTurnId,
[plan.turnId]: plan,
},
- lastSequence: Math.max(state.lastSequence, event.sequence),
entitySequences: {
...state.entitySequences,
[key]: event.sequence,
@@ -255,10 +238,7 @@ function applyDomainEvent(state: WorkspaceState, event: Event): WorkspaceState {
if (event.type === "goal.updated") {
const key = `goal:${event.threadId}`;
if ((state.entitySequences[key] ?? 0) > event.sequence) {
- return {
- ...state,
- lastSequence: Math.max(state.lastSequence, event.sequence),
- };
+ return state;
}
const rawGoal = event.payload.goal;
const rawOutcome = event.payload.outcome;
@@ -268,7 +248,6 @@ function applyDomainEvent(state: WorkspaceState, event: Event): WorkspaceState {
goalOutcome: isRecord(rawOutcome)
? (rawOutcome as unknown as GoalOutcome)
: null,
- lastSequence: Math.max(state.lastSequence, event.sequence),
entitySequences: {
...state.entitySequences,
[key]: event.sequence,
@@ -317,7 +296,6 @@ function applyDomainEvent(state: WorkspaceState, event: Event): WorkspaceState {
artifact && accepted.get("artifact")
? upsert(state.artifacts, artifact)
: state.artifacts,
- lastSequence: Math.max(state.lastSequence, event.sequence),
entitySequences: nextSequences,
};
}
@@ -363,7 +341,6 @@ export function workspaceReducer(
plansByTurnId: {},
goal: null,
goalOutcome: null,
- lastSequence: 0,
entitySequences: {},
selectedItemId: null,
};
@@ -395,7 +372,6 @@ export function workspaceReducer(
plansByTurnId: selected ? {} : state.plansByTurnId,
goal: selected ? null : state.goal,
goalOutcome: selected ? null : state.goalOutcome,
- lastSequence: selected ? 0 : state.lastSequence,
entitySequences: selected ? {} : state.entitySequences,
selectedItemId: selected ? null : state.selectedItemId,
};
@@ -412,7 +388,6 @@ export function workspaceReducer(
plansByTurnId: {},
goal: null,
goalOutcome: null,
- lastSequence: 0,
entitySequences: {},
selectedItemId: null,
};
@@ -427,7 +402,6 @@ export function workspaceReducer(
plansByTurnId: {},
goal: null,
goalOutcome: null,
- lastSequence: 0,
entitySequences: {},
selectedItemId: null,
};
diff --git a/desktop/src/components/RuntimeNotice.module.css b/desktop/src/components/RuntimeNotice.module.css
index 1ef1e4da6..80b396e0a 100644
--- a/desktop/src/components/RuntimeNotice.module.css
+++ b/desktop/src/components/RuntimeNotice.module.css
@@ -28,12 +28,10 @@
}
.copy span {
- overflow: hidden;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.45;
- text-overflow: ellipsis;
- white-space: nowrap;
+ overflow-wrap: anywhere;
}
.actions {
@@ -74,8 +72,4 @@
right: 8px;
width: calc(100vw - 16px);
}
-
- .copy span {
- white-space: normal;
- }
}
diff --git a/desktop/src/components/RuntimeNotice.test.tsx b/desktop/src/components/RuntimeNotice.test.tsx
new file mode 100644
index 000000000..71e03dd7d
--- /dev/null
+++ b/desktop/src/components/RuntimeNotice.test.tsx
@@ -0,0 +1,81 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+
+import { __setLocaleForTests, initI18n } from "../app/i18n";
+import type { SidecarStatus } from "../rpc/contracts";
+import { RuntimeNotice } from "./RuntimeNotice";
+
+const stopped: SidecarStatus = {
+ phase: "stopped",
+ message: "The browser needs a new access link",
+ errorCode: "AUTH_REQUIRED",
+ launchSource: "browser",
+ serverInfo: null,
+};
+
+beforeEach(() => {
+ initI18n();
+ __setLocaleForTests("en");
+});
+afterEach(() => {
+ cleanup();
+ __setLocaleForTests("en");
+});
+
+it.each(["en", "zh-CN"] as const)(
+ "explains browser authorization in %s without offering a futile reconnect",
+ (locale) => {
+ __setLocaleForTests(locale);
+ render(
+ ,
+ );
+ expect(screen.getByRole("alert").textContent).toContain("deepcode web");
+ expect(screen.getByRole("alert").textContent).toContain(
+ locale === "en" ? "Browser access required" : "需要授权浏览器访问",
+ );
+ expect(screen.getByRole("alert").textContent).not.toContain("APP_SERVER_OFFLINE");
+ expect(screen.queryByRole("button")).toBeNull();
+ },
+);
+
+it("keeps reconnection available for network failures", () => {
+ const reconnect = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Reconnect" }));
+ expect(reconnect).toHaveBeenCalledOnce();
+ expect(screen.getByRole("alert").textContent).toContain("Network unavailable");
+});
+
+it("keeps native restart and ordinary application error dismissal", () => {
+ const restart = vi.fn(), dismiss = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByRole("alert").textContent).toContain("Trust this project");
+ fireEvent.click(screen.getByRole("button", { name: "Restart service" }));
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss error" }));
+ expect(restart).toHaveBeenCalledOnce();
+ expect(dismiss).toHaveBeenCalledOnce();
+});
diff --git a/desktop/src/components/RuntimeNotice.tsx b/desktop/src/components/RuntimeNotice.tsx
index d9578a19b..06d225d19 100644
--- a/desktop/src/components/RuntimeNotice.tsx
+++ b/desktop/src/components/RuntimeNotice.tsx
@@ -10,6 +10,7 @@ interface RuntimeNoticeProps {
busy: boolean;
onRestart(): void;
onDismissError(): void;
+ reconnectOnly?: boolean;
}
export function RuntimeNotice({
@@ -18,20 +19,25 @@ export function RuntimeNotice({
busy,
onRestart,
onDismissError,
+ reconnectOnly = false,
}: RuntimeNoticeProps) {
const { t } = useTranslation();
if (!error && runtime.phase !== "crashed" && runtime.phase !== "stopped") return null;
const serviceOffline = runtime.phase === "crashed" || runtime.phase === "stopped";
- const message = error?.message ?? runtime.message ?? t("runtime.offline", "The local App Server is unavailable.");
+ const code = runtime.errorCode ?? error?.code;
+ const authRequired = reconnectOnly && code === "AUTH_REQUIRED";
+ const message = authRequired
+ ? t("runtime.browserAuthHelp", "Run deepcode web in your terminal to open a new browser access link. No DeepCode account is needed.")
+ : (runtime.errorCode ? runtime.message : error?.message) ?? runtime.message ?? t("runtime.offline", "The local App Server is unavailable.");
return (
- {error?.code ?? "APP_SERVER_OFFLINE"}
+ {authRequired ? t("runtime.browserAuthRequired", "Browser access required") : code ?? "APP_SERVER_OFFLINE"}
{message}
- {error ? (
+ {error && !authRequired ? (
) : null}
- {serviceOffline ? (
+ {serviceOffline && !authRequired ? (
- {t("runtime.restart", "Restart service")}
+ {reconnectOnly || runtime.serverInfo?.serviceInfo?.shutdownScope === "connection"
+ ? t("runtime.reconnect", "Reconnect")
+ : t("runtime.restart", "Restart service")}
) : null}
diff --git a/desktop/src/desktopMain.tsx b/desktop/src/desktopMain.tsx
new file mode 100644
index 000000000..8fe786493
--- /dev/null
+++ b/desktop/src/desktopMain.tsx
@@ -0,0 +1,22 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import { initI18n } from "./app/i18n";
+import { App } from "./App";
+import { tauriRuntime, configureNativeDialogs } from "./rpc/tauriRuntime";
+import "./styles/tokens.css";
+
+initI18n();
+configureNativeDialogs();
+
+const root = document.getElementById("root");
+
+if (!root) {
+ throw new Error("DeepCode desktop root element was not found");
+}
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/desktop/src/features/automations/AutomationsPage.test.tsx b/desktop/src/features/automations/AutomationsPage.test.tsx
index 94a033f44..2ea50b162 100644
--- a/desktop/src/features/automations/AutomationsPage.test.tsx
+++ b/desktop/src/features/automations/AutomationsPage.test.tsx
@@ -18,7 +18,7 @@ import type {
} from "../../generated/app-server";
import type {
AnyRpcNotification,
- DesktopRuntime,
+ ClientRuntime,
DesktopUpdateInfo,
DesktopUpdateProgress,
RpcMethod,
@@ -98,7 +98,7 @@ function completedRun(automation: Automation): AutomationRun {
};
}
-class AutomationRuntime implements DesktopRuntime {
+class AutomationRuntime implements ClientRuntime {
readonly requests: Array<{ method: RpcMethod; params: unknown }> = [];
automations = [definition()];
runs = [completedRun(this.automations[0])];
diff --git a/desktop/src/features/automations/AutomationsPage.tsx b/desktop/src/features/automations/AutomationsPage.tsx
index d7163f39f..4ce26afbd 100644
--- a/desktop/src/features/automations/AutomationsPage.tsx
+++ b/desktop/src/features/automations/AutomationsPage.tsx
@@ -16,7 +16,7 @@ import type {
Thread,
} from "../../generated/app-server";
import { confirmAction } from "../../platform/confirmAction";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import styles from "../management/ManagementWorkspace.module.css";
import {
automationIntervalInput,
@@ -27,7 +27,7 @@ import {
import { useAutomations } from "./useAutomations";
interface AutomationsPageProps {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
onThreadCreated(thread: Thread): void;
onOpenThread(threadId: string): void;
diff --git a/desktop/src/features/automations/useAutomations.ts b/desktop/src/features/automations/useAutomations.ts
index 272a2ac64..696c23be0 100644
--- a/desktop/src/features/automations/useAutomations.ts
+++ b/desktop/src/features/automations/useAutomations.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { MethodParams, MethodResults } from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
type AutomationInventory = MethodResults["automation/list"];
type AutomationRunPage = MethodResults["automation/runs"];
@@ -14,7 +14,7 @@ function message(error: unknown): string {
}
export function useAutomations(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
projectId: string | null,
expandedRunAutomationId: string | null = null,
) {
diff --git a/desktop/src/features/execution/Composer.tsx b/desktop/src/features/execution/Composer.tsx
index 2ce5c795c..6f2242496 100644
--- a/desktop/src/features/execution/Composer.tsx
+++ b/desktop/src/features/execution/Composer.tsx
@@ -29,7 +29,7 @@ import {
turnExecutionAccessLabel,
turnExecutionAccessState,
} from "../../app/accessPreset";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import type { TranscriptMode } from "../thread/transcriptMode";
import { PresetPicker } from "../presets/PresetPicker";
import { usePresetCatalog } from "../presets/usePresetCatalog";
@@ -53,7 +53,7 @@ interface ComposerProps {
conversationStarted: boolean;
executingTurn: Turn | null;
queuedTurns: readonly Turn[];
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
thread: Thread | null;
settings: SettingsSnapshot | null;
@@ -281,15 +281,19 @@ export function Composer({
const pickContextFiles = async () => {
setContextError(null);
- const selected = await onPickContextFiles();
- const workspace = thread?.workspacePath;
- const accepted = workspace
- ? selected.filter((path) => isInsideWorkspace(path, workspace))
- : [];
- if (accepted.length !== selected.length) {
- setContextError("Only files inside this Session workspace can be attached.");
+ try {
+ const selected = await onPickContextFiles();
+ const workspace = thread?.workspacePath;
+ const accepted = workspace
+ ? selected.filter((path) => isInsideWorkspace(path, workspace))
+ : [];
+ if (accepted.length !== selected.length) {
+ setContextError("Only files inside this Session workspace can be attached.");
+ }
+ addAttachments(accepted);
+ } catch (error) {
+ setContextError(error instanceof Error ? error.message : String(error));
}
- addAttachments(accepted);
};
const onKeyDown = (event: KeyboardEvent
) => {
diff --git a/desktop/src/features/execution/ModelPicker.tsx b/desktop/src/features/execution/ModelPicker.tsx
index bd7fbc0db..b6da9ee4f 100644
--- a/desktop/src/features/execution/ModelPicker.tsx
+++ b/desktop/src/features/execution/ModelPicker.tsx
@@ -15,12 +15,12 @@ import type {
SettingsSnapshot,
Thread,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import { useConnectionCatalog } from "../settings/useConnectionCatalog";
import styles from "./ModelPicker.module.css";
interface ModelPickerProps {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
thread: Thread | null;
settings: SettingsSnapshot | null;
diff --git a/desktop/src/features/extensions/SkillsPage.tsx b/desktop/src/features/extensions/SkillsPage.tsx
index 210a3549e..20f1ab123 100644
--- a/desktop/src/features/extensions/SkillsPage.tsx
+++ b/desktop/src/features/extensions/SkillsPage.tsx
@@ -6,13 +6,13 @@ import type {
Project,
SkillInfo,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import styles from "../management/ManagementWorkspace.module.css";
import { MarkdownContent } from "../thread/MarkdownContent";
import { useSkillManagement } from "./useSkillManagement";
interface SkillsPageProps {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
onCreateSkill(skill: SkillInfo): Promise;
}
diff --git a/desktop/src/features/extensions/useSkillManagement.ts b/desktop/src/features/extensions/useSkillManagement.ts
index 19f348250..756b1a388 100644
--- a/desktop/src/features/extensions/useSkillManagement.ts
+++ b/desktop/src/features/extensions/useSkillManagement.ts
@@ -4,7 +4,7 @@ import type {
ConfigScope,
SkillDetail,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import { useSkillCatalog } from "../skills/useSkillCatalog";
interface SkillManagementState {
@@ -26,7 +26,7 @@ function message(error: unknown): string {
}
export function useSkillManagement(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
projectId: string | null,
) {
const catalog = useSkillCatalog(runtime, projectId);
diff --git a/desktop/src/features/inspector/ArtifactsPanel.tsx b/desktop/src/features/inspector/ArtifactsPanel.tsx
index 6dd9681b8..025f5ee52 100644
--- a/desktop/src/features/inspector/ArtifactsPanel.tsx
+++ b/desktop/src/features/inspector/ArtifactsPanel.tsx
@@ -4,7 +4,7 @@ import type {
Artifact,
WorkflowRun,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import { InspectorEmpty } from "./InspectorEmpty";
import { formatBytes } from "./inspectorFormat";
import styles from "./Inspector.module.css";
@@ -17,7 +17,7 @@ interface ArtifactPreview {
}
interface ArtifactsPanelProps {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
workflow: WorkflowRun | null;
artifacts: Artifact[];
}
diff --git a/desktop/src/features/inspector/FilesPanel.tsx b/desktop/src/features/inspector/FilesPanel.tsx
index 6a9559202..ab10079a1 100644
--- a/desktop/src/features/inspector/FilesPanel.tsx
+++ b/desktop/src/features/inspector/FilesPanel.tsx
@@ -1,4 +1,4 @@
-import { lazy, Suspense } from "react";
+import { lazy, Suspense, useState } from "react";
import { useSystemDarkMode } from "../../app/useSystemDarkMode";
import { confirmAction } from "../../platform/confirmAction";
@@ -13,13 +13,16 @@ interface FilesPanelProps {
trusted: boolean;
hasActiveTurn: boolean;
workbench: CodeWorkbenchController;
+ onDownload?: (path: string) => Promise;
}
export function FilesPanel({
trusted,
hasActiveTurn,
workbench,
+ onDownload,
}: FilesPanelProps) {
+ const [downloadError, setDownloadError] = useState(null);
const darkMode = useSystemDarkMode();
const dirty = Boolean(
workbench.file && workbench.draft !== workbench.file.content,
@@ -66,6 +69,10 @@ export function FilesPanel({
{workbench.file.path}
+ {onDownload && {
+ setDownloadError(null);
+ void onDownload(workbench.file!.path).catch((error) => setDownloadError(String(error)));
+ }}>Download }
{workbench.file.truncated ? (
Truncated · read-only
) : dirty ? (
@@ -86,6 +93,7 @@ export function FilesPanel({
+ {downloadError && {downloadError}
}
Loading editor… }>
runtime.downloadFile!(thread.id, path) : undefined}
trusted={trusted}
hasActiveTurn={hasActiveTurn}
workbench={workbench}
diff --git a/desktop/src/features/management/ManagementWorkspace.tsx b/desktop/src/features/management/ManagementWorkspace.tsx
index b8505fb1c..91dd085cc 100644
--- a/desktop/src/features/management/ManagementWorkspace.tsx
+++ b/desktop/src/features/management/ManagementWorkspace.tsx
@@ -1,6 +1,6 @@
import type { Project, SkillInfo, Thread } from "../../generated/app-server";
import type { DesktopDestination } from "../../app/useDesktopUi";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import { AutomationsPage } from "../automations/AutomationsPage";
import { SkillsPage } from "../extensions/SkillsPage";
import { PluginsPage } from "../plugins/PluginsPage";
@@ -8,7 +8,7 @@ import { McpPage } from "../mcp/McpPage";
interface ManagementWorkspaceProps {
destination: DesktopDestination;
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
onThreadCreated(thread: Thread): void;
onOpenThread(threadId: string): void;
diff --git a/desktop/src/features/mcp/McpPage.test.tsx b/desktop/src/features/mcp/McpPage.test.tsx
index 9e3d0f58d..15bb06753 100644
--- a/desktop/src/features/mcp/McpPage.test.tsx
+++ b/desktop/src/features/mcp/McpPage.test.tsx
@@ -8,7 +8,7 @@ import type {
MethodResults,
Project,
} from "../../generated/app-server";
-import type { DesktopRuntime, RpcMethod } from "../../rpc/contracts";
+import type { ClientRuntime, RpcMethod } from "../../rpc/contracts";
import { McpPage } from "./McpPage";
const project: Project = {
@@ -202,7 +202,7 @@ test("adds a credential-bound stdio MCP server through the shared RPC", async ()
const runtime = new McpRuntime();
render(
,
);
@@ -283,7 +283,7 @@ test("shows Plugin MCP servers without exposing native edit actions", async () =
render(
,
);
@@ -298,7 +298,7 @@ test("adds a bundled preset disabled, tests it, then enables agent use", async (
const runtime = new McpRuntime();
render(
,
);
diff --git a/desktop/src/features/mcp/McpPage.tsx b/desktop/src/features/mcp/McpPage.tsx
index 5e00ce93a..d0dca9a08 100644
--- a/desktop/src/features/mcp/McpPage.tsx
+++ b/desktop/src/features/mcp/McpPage.tsx
@@ -23,7 +23,7 @@ import type {
McpServerInfo,
Project,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import styles from "../management/ManagementWorkspace.module.css";
import { useMcpCatalog } from "./useMcpCatalog";
@@ -82,7 +82,7 @@ export function McpPage({
runtime,
project,
}: {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
}) {
const catalog = useMcpCatalog(runtime, project);
diff --git a/desktop/src/features/mcp/useMcpCatalog.test.tsx b/desktop/src/features/mcp/useMcpCatalog.test.tsx
index 70631759d..400f641f3 100644
--- a/desktop/src/features/mcp/useMcpCatalog.test.tsx
+++ b/desktop/src/features/mcp/useMcpCatalog.test.tsx
@@ -7,7 +7,7 @@ import type {
McpProbeResult,
Project,
} from "../../generated/app-server";
-import type { DesktopRuntime, RpcMethod } from "../../rpc/contracts";
+import type { ClientRuntime, RpcMethod } from "../../rpc/contracts";
import { useMcpCatalog } from "./useMcpCatalog";
function deferred() {
@@ -78,7 +78,7 @@ describe("useMcpCatalog project ownership", () => {
const runtime = {
request,
onNotification: async () => () => undefined,
- } as unknown as DesktopRuntime;
+ } as unknown as ClientRuntime;
const { result, rerender } = renderHook(
({ selectedProject }) => useMcpCatalog(runtime, selectedProject),
diff --git a/desktop/src/features/mcp/useMcpCatalog.ts b/desktop/src/features/mcp/useMcpCatalog.ts
index 2ab5d311c..e2407a24f 100644
--- a/desktop/src/features/mcp/useMcpCatalog.ts
+++ b/desktop/src/features/mcp/useMcpCatalog.ts
@@ -9,7 +9,7 @@ import type {
McpProbeResult,
Project,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
interface McpCatalogState {
key: string;
@@ -19,7 +19,7 @@ interface McpCatalogState {
error: string | null;
}
-export function useMcpCatalog(runtime: DesktopRuntime, project: Project | null) {
+export function useMcpCatalog(runtime: ClientRuntime, project: Project | null) {
const projectId = project?.id;
const catalogKey = projectId ?? "__user__";
const generation = useRef(0);
diff --git a/desktop/src/features/navigation/DesktopSidebar.module.css b/desktop/src/features/navigation/DesktopSidebar.module.css
index 47ed64d88..953b81c64 100644
--- a/desktop/src/features/navigation/DesktopSidebar.module.css
+++ b/desktop/src/features/navigation/DesktopSidebar.module.css
@@ -1,7 +1,7 @@
.sidebar {
display: flex;
min-width: 0;
- height: calc(100vh - 16px);
+ height: calc(100% - 16px);
flex-direction: column;
margin: 8px 4px 8px 8px;
padding: 11px 10px 10px;
diff --git a/desktop/src/features/plugins/PluginsPage.test.tsx b/desktop/src/features/plugins/PluginsPage.test.tsx
index 384c87c1d..cbd00a00b 100644
--- a/desktop/src/features/plugins/PluginsPage.test.tsx
+++ b/desktop/src/features/plugins/PluginsPage.test.tsx
@@ -8,7 +8,7 @@ import type {
} from "../../generated/app-server";
import type {
AnyRpcNotification,
- DesktopRuntime,
+ ClientRuntime,
RpcMethod,
} from "../../rpc/contracts";
import { PluginsPage } from "./PluginsPage";
@@ -96,7 +96,7 @@ afterEach(() => {
test("adds, disables, and unregisters a local Plugin", async () => {
const runtime = new PluginRuntime();
vi.spyOn(window, "confirm").mockReturnValue(true);
- render( );
+ render( );
expect(screen.getByText(/MCP servers join the shared tool runtime/)).toBeTruthy();
await screen.findByText(/No Plugins registered/);
@@ -120,7 +120,7 @@ test("adds, disables, and unregisters a local Plugin", async () => {
test("reloads the registry after plugins.changed", async () => {
const runtime = new PluginRuntime();
- render( );
+ render( );
await screen.findByText(/No Plugins registered/);
runtime.catalog = { ...runtime.catalog, plugins: [plugin] };
diff --git a/desktop/src/features/plugins/PluginsPage.tsx b/desktop/src/features/plugins/PluginsPage.tsx
index b26442fc1..1b90c25e2 100644
--- a/desktop/src/features/plugins/PluginsPage.tsx
+++ b/desktop/src/features/plugins/PluginsPage.tsx
@@ -1,10 +1,10 @@
import { FolderInput, Power, RefreshCw, Trash2 } from "lucide-react";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import styles from "../management/ManagementWorkspace.module.css";
import { usePluginCatalog } from "./usePluginCatalog";
-export function PluginsPage({ runtime }: { runtime: DesktopRuntime }) {
+export function PluginsPage({ runtime }: { runtime: ClientRuntime }) {
const catalog = usePluginCatalog(runtime);
const addPlugin = async () => {
diff --git a/desktop/src/features/plugins/usePluginCatalog.ts b/desktop/src/features/plugins/usePluginCatalog.ts
index 9380325b9..701c9cede 100644
--- a/desktop/src/features/plugins/usePluginCatalog.ts
+++ b/desktop/src/features/plugins/usePluginCatalog.ts
@@ -4,7 +4,7 @@ import type {
PluginCatalogResult,
PluginInfo,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
interface PluginCatalogState extends PluginCatalogResult {
loading: boolean;
@@ -23,7 +23,7 @@ function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
-export function usePluginCatalog(runtime: DesktopRuntime) {
+export function usePluginCatalog(runtime: ClientRuntime) {
const [state, setState] = useState(emptyState);
const [selectedId, setSelectedId] = useState(null);
const generation = useRef(0);
diff --git a/desktop/src/features/presets/usePresetCatalog.ts b/desktop/src/features/presets/usePresetCatalog.ts
index 319a8c492..b1b1708e7 100644
--- a/desktop/src/features/presets/usePresetCatalog.ts
+++ b/desktop/src/features/presets/usePresetCatalog.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { AgentPresetEntry } from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
export interface PresetCatalogState {
entries: AgentPresetEntry[];
@@ -28,7 +28,7 @@ function errorMessage(error: unknown): string {
* an effect and a late-landing response for a previous owner is ignored.
*/
export function usePresetCatalog(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
projectId: string | null,
threadId: string | null,
): PresetCatalogState & { select(presetId: string | null): Promise } {
diff --git a/desktop/src/features/settings/ConnectionProbe.test.tsx b/desktop/src/features/settings/ConnectionProbe.test.tsx
new file mode 100644
index 000000000..aad9f643f
--- /dev/null
+++ b/desktop/src/features/settings/ConnectionProbe.test.tsx
@@ -0,0 +1,88 @@
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
+import { afterEach, expect, test, vi } from "vitest";
+import type { ProviderTestResult } from "../../generated/app-server";
+import type { ConnectionCatalogController } from "./useConnectionCatalog";
+import { ConnectionProbe } from "./ConnectionProbe";
+
+afterEach(cleanup);
+
+test("unsaved probe uses current form and hides a result after edits", async () => {
+ let complete!: (value: ProviderTestResult) => void;
+ const probe = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ complete = resolve;
+ }),
+ );
+ const controller = { test: probe } as unknown as ConnectionCatalogController;
+ const connection = {
+ id: "local",
+ protocol: "openai_responses" as const,
+ apiBase: "http://localhost:1234/v1",
+ };
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "model" } });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Verify Agent compatibility" }),
+ );
+ expect(probe).toHaveBeenCalledWith("local", "model", {
+ connection,
+ mode: "agent",
+ });
+ rerender(
+ ,
+ );
+ await act(async () =>
+ complete({
+ connectionId: "local",
+ status: "ready",
+ ok: true,
+ latencyMs: 1,
+ modelCount: 1,
+ error: null,
+ stages: [
+ {
+ id: "credential",
+ status: "passed",
+ detail: "Credential",
+ latencyMs: 0,
+ modelCount: null,
+ modelId: null,
+ },
+ {
+ id: "catalog",
+ status: "passed",
+ detail: "Catalog",
+ latencyMs: 0,
+ modelCount: 1,
+ modelId: null,
+ },
+ {
+ id: "model",
+ status: "passed",
+ detail: "Model",
+ latencyMs: 1,
+ modelCount: null,
+ modelId: "model",
+ },
+ ],
+ }),
+ );
+ expect(screen.queryByText("Model request verified")).toBeNull();
+ expect(
+ screen.getByText(
+ "Settings changed. Verify again to check this configuration.",
+ ),
+ ).toBeTruthy();
+});
diff --git a/desktop/src/features/settings/ConnectionProbe.tsx b/desktop/src/features/settings/ConnectionProbe.tsx
new file mode 100644
index 000000000..8bf5ae2ad
--- /dev/null
+++ b/desktop/src/features/settings/ConnectionProbe.tsx
@@ -0,0 +1,100 @@
+import { useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import type {
+ ProviderTestResult,
+ ProviderUpsertParams,
+} from "../../generated/app-server";
+import type { ConnectionCatalogController } from "./useConnectionCatalog";
+import { ConnectionVerification } from "./ConnectionVerification";
+import styles from "./ConnectionSettings.module.css";
+
+export function ConnectionProbe({
+ connection,
+ controller,
+}: {
+ connection: ProviderUpsertParams["connection"];
+ controller: ConnectionCatalogController;
+}) {
+ const { t } = useTranslation();
+ const [model, setModel] = useState("");
+ const [busy, setBusy] = useState(false);
+ const inFlight = useRef(false);
+ const [outcome, setOutcome] = useState<{
+ fingerprint: string;
+ result?: ProviderTestResult;
+ error?: string;
+ } | null>(null);
+ const fingerprint = JSON.stringify([connection, model.trim()]);
+ const test = async (mode: "quick" | "agent") => {
+ if (inFlight.current) return;
+ inFlight.current = true;
+ setBusy(true);
+ setOutcome(null);
+ try {
+ const result = await controller.test(connection.id, model.trim(), {
+ connection,
+ mode,
+ });
+ setOutcome({ fingerprint, result });
+ } catch (cause) {
+ setOutcome({
+ fingerprint,
+ error: cause instanceof Error ? cause.message : String(cause),
+ });
+ } finally {
+ inFlight.current = false;
+ setBusy(false);
+ }
+ };
+ const current = outcome?.fingerprint === fingerprint ? outcome : null;
+ return (
+
+ {t("provider.verifyDraft", "Verify current settings")}
+
+ {t("provider.verifyModel", "Model to verify")}
+ setModel(event.target.value)}
+ placeholder="provider/model-id"
+ />
+
+
+ {t(
+ "provider.probeBudget",
+ "Uses the current form without saving. Agent verification makes up to 3 model requests within 90 seconds, using only a local verification tool. Provider reasoning can increase the token budget.",
+ )}
+
+
+ void test("quick")}
+ >
+ {t("provider.quick", "Quick test")}
+
+ void test("agent")}
+ >
+ {t("provider.agentTest", "Verify Agent compatibility")}
+
+
+ {busy ? (
+ {t("provider.testing", "Verification running…")}
+ ) : null}
+ {current?.error ? {current.error}
: null}
+ {current?.result ? (
+
+ ) : null}
+ {outcome && !current ? (
+
+ {t(
+ "provider.probeStale",
+ "Settings changed. Verify again to check this configuration.",
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/settings/ConnectionSettings.tsx b/desktop/src/features/settings/ConnectionSettings.tsx
index 7fa9a0b89..f0ec4ef1f 100644
--- a/desktop/src/features/settings/ConnectionSettings.tsx
+++ b/desktop/src/features/settings/ConnectionSettings.tsx
@@ -15,13 +15,16 @@ import type {
ConnectionInfo,
ManualModelEntry,
ProviderTestResult,
- ProviderUpsertParams,
} from "../../generated/app-server";
import { useEscapeLayer } from "../../app/escapeLayer";
import { confirmAction } from "../../platform/confirmAction";
import type { ConnectionCatalogController } from "./useConnectionCatalog";
import { ConnectionVerification } from "./ConnectionVerification";
import { ModelListEditor } from "./ModelListEditor";
+import { type Draft, emptyDraft, connectionMutation } from "./connectionDraft";
+import { ProtocolSettings } from "./ProtocolSettings";
+import { ConnectionProbe } from "./ConnectionProbe";
+import { ProviderLogin } from "./ProviderLogin";
import styles from "./ConnectionSettings.module.css";
interface ConnectionSettingsProps {
@@ -33,40 +36,6 @@ interface ConnectionSettingsProps {
scope: ConfigScope;
}
-interface Draft {
- id: string;
- label: string;
- template: string;
- adapter: "openai_compat" | "anthropic";
- apiBase: string;
- /** Environment-variable reference — the advanced alternative to a
- * stored key (the write-only input is the primary path, dsh style). */
- apiKeyEnv: string;
- apiKey: string;
- clearApiKey: boolean;
- modelCatalog: "auto" | "openrouter" | "openai" | "anthropic" | "manual";
- manualModels: ManualModelEntry[];
- /** True when the launch environment currently provides this key: it
- * outranks a pasted key, so the form must say so instead of letting a
- * paste silently lose. */
- environmentShadows: boolean;
- shadowingEnvName: string;
-}
-
-const emptyDraft: Draft = {
- id: "",
- label: "",
- template: "",
- adapter: "openai_compat",
- apiBase: "",
- apiKeyEnv: "",
- apiKey: "",
- clearApiKey: false,
- modelCatalog: "auto",
- manualModels: [],
- environmentShadows: false,
- shadowingEnvName: "",
-};
export function ConnectionSettings({
controller,
@@ -126,6 +95,9 @@ export function ConnectionSettings({
label: connection.label,
template: connection.providerName,
adapter: connection.adapter,
+ protocol: connection.protocol ?? "auto",
+ auth: connection.auth ?? "api_key",
+ compat: connection.compat ?? {},
apiBase: connection.apiBase ?? "",
apiKeyEnv: connection.apiKeyEnv ?? "",
apiKey: "",
@@ -155,6 +127,14 @@ export function ConnectionSettings({
template: template.name,
adapter: template.adapter === "anthropic" ? "anthropic" : "openai_compat",
apiBase: template.defaultApiBase ?? "",
+ protocol: "auto",
+ auth: template.local ? "none" : "api_key",
+ compat: {},
+ apiKey: "",
+ apiKeyEnv: "",
+ clearApiKey: false,
+ environmentShadows: false,
+ shadowingEnvName: "",
modelCatalog: "auto",
manualModels: [],
}));
@@ -165,24 +145,7 @@ export function ConnectionSettings({
setSaving(true);
try {
const connectionId = editing.id.trim().toLocaleLowerCase();
- const connection: ProviderUpsertParams["connection"] = {
- id: connectionId,
- label: editing.label.trim() || editing.id.trim(),
- template: editing.template,
- adapter: editing.adapter,
- apiBase: editing.apiBase.trim() || null,
- apiKeyEnv: editing.apiKeyEnv.trim() || null,
- modelCatalog: editing.modelCatalog,
- manualModels: editing.manualModels
- .map((entry) => ({ ...entry, id: entry.id.trim() }))
- .filter((entry) => entry.id)
- .map((entry) => (hasDeclarations(entry) ? entry : entry.id)),
- enabled: true,
- };
- if (editing.apiKey.trim()) {
- connection.apiKey = editing.apiKey.trim();
- }
- if (editing.clearApiKey) connection.clearApiKey = true;
+ const connection = connectionMutation(editing);
await controller.upsert(connection);
setEditing(null);
setTestingId(connectionId);
@@ -231,13 +194,7 @@ export function ConnectionSettings({
try {
// Probe THE FORM AS SHOWN (dsh's rule): an unsaved base URL or a key
// typed but not yet stored takes part; nothing is written.
- const result = await controller.discover({
- ...(editingExisting
- ? { connectionId: editing.id.trim().toLocaleLowerCase() }
- : { template: editing.template }),
- ...(editing.apiBase.trim() ? { apiBase: editing.apiBase.trim() } : {}),
- ...(editing.apiKey.trim() ? { apiKey: editing.apiKey.trim() } : {}),
- });
+ const result = await controller.discover({ connection: connectionMutation(editing) });
setModelFetchState({
editorId: editingId,
loading: false,
@@ -412,6 +369,7 @@ export function ConnectionSettings({
{modelFaceLabel(connection, result)}
+ {connection.auth === "oauth" ? : null}
{result ? (
) : testingId === connection.id ? (
@@ -530,7 +488,8 @@ export function ConnectionSettings({
) : null}
- {!selectedTemplate?.local ? (
+
+ {editing.auth === "api_key" ? (
Credential
{editing.environmentShadows ? (
@@ -646,6 +605,7 @@ export function ConnectionSettings({
>
) : null}
setEditing({ ...editing, manualModels })
@@ -658,6 +618,7 @@ export function ConnectionSettings({
) : null}
+
Advanced connection settings
@@ -809,14 +770,6 @@ export function ConnectionSettings({
);
}
-function hasDeclarations(entry: ManualModelEntry): boolean {
- return (
- entry.label != null ||
- entry.contextWindow != null ||
- entry.maxOutputTokens != null ||
- entry.reasoningEfforts != null
- );
-}
function modelFaceLabel(
connection: ConnectionInfo,
@@ -843,6 +796,8 @@ function credentialLabel(connection: ConnectionInfo): string {
return "legacy config";
case "not_required":
return "no key required";
+ case "oauth":
+ return "signed-in account";
default:
return "no key";
}
@@ -856,6 +811,8 @@ function connectionStatus(
if (result?.status === "connected") return "Catalog connected";
if (result?.status === "limited") return "Model check needed";
if (result?.status === "error") return "Needs attention";
+ if (connection.auth === "none") return "No authentication required";
+ if (connection.auth === "oauth") return connection.configured ? "Signed in" : "Sign-in required";
return connection.configured ? "Credential saved" : "Needs credential";
}
diff --git a/desktop/src/features/settings/ConnectionVerification.tsx b/desktop/src/features/settings/ConnectionVerification.tsx
index a1c07831b..2f58d6d32 100644
--- a/desktop/src/features/settings/ConnectionVerification.tsx
+++ b/desktop/src/features/settings/ConnectionVerification.tsx
@@ -20,21 +20,33 @@ const STAGE_LABELS: Record = {
credential: "Credential",
catalog: "Model catalog",
model: "Model request",
+ stream: "Streaming",
+ tool: "Tool call",
+ continuation: "Tool result continuation",
+ reasoning: "Reasoning",
+ image: "Image input",
};
export function ConnectionVerification({
result,
compact = false,
}: ConnectionVerificationProps) {
+ const label =
+ result.status === "ready" &&
+ result.stages.some(
+ (stage) => stage.id === "continuation" && stage.status === "passed",
+ )
+ ? "Agent tool round trip verified"
+ : statusLabel(result.status);
return (
- {statusLabel(result.status)}
+ {label}
{result.latencyMs} ms total
@@ -62,7 +74,7 @@ function StageIcon({ stage }: { stage: ProviderVerificationStage }) {
function statusLabel(status: ProviderTestResult["status"]): string {
switch (status) {
case "ready":
- return "Ready for agent work";
+ return "Model request verified";
case "connected":
return "Catalog connected";
case "limited":
diff --git a/desktop/src/features/settings/ModelListEditor.tsx b/desktop/src/features/settings/ModelListEditor.tsx
index 492cea6db..69a54f134 100644
--- a/desktop/src/features/settings/ModelListEditor.tsx
+++ b/desktop/src/features/settings/ModelListEditor.tsx
@@ -4,8 +4,8 @@
* row's own disclosure. Capacity fields accept K/M suffixes (1M = 1000K)
* and store plain counts; text that does not parse stays on screen so the
* save-time rejection names a row that is still visible. Rows are the
- * config file's own entries — unknown future fields survive because plain
- * ids stay plain and objects are edited field-wise, never rebuilt.
+ * config file's own entries; supported declarations are edited field-wise
+ * while an entry with only an id stays a plain id when saved.
*
* Effort declarations (`reasoningEfforts`) are config-file-only, exactly
* as in dsh: a per-model ladder is a capability statement, not a form
@@ -21,19 +21,32 @@
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
-import type { ManualModelEntry } from "../../generated/app-server";
+import { useTranslation } from "react-i18next";
+import { CompatEditor } from "./ProtocolSettings";
+import type {
+ ManualModelEntry,
+ ProviderProtocol,
+} from "../../generated/app-server";
import { capacityText, parseCapacity } from "./modelCapacity";
import styles from "./ConnectionSettings.module.css";
interface ModelListEditorProps {
+ protocol?: ProviderProtocol;
entries: ManualModelEntry[];
onChange(entries: ManualModelEntry[]): void;
}
-export function ModelListEditor({ entries, onChange }: ModelListEditorProps) {
+export function ModelListEditor({
+ entries,
+ onChange,
+ protocol = "auto",
+}: ModelListEditorProps) {
+ const { t } = useTranslation();
const update = (index: number, patch: Partial) => {
onChange(
- entries.map((entry, at) => (at === index ? { ...entry, ...patch } : entry)),
+ entries.map((entry, at) =>
+ at === index ? { ...entry, ...patch } : entry,
+ ),
);
};
@@ -64,9 +77,7 @@ export function ModelListEditor({ entries, onChange }: ModelListEditorProps) {
type="button"
className={styles.modelRowRemove}
aria-label={`Remove model ${entry.id || index + 1}`}
- onClick={() =>
- onChange(entries.filter((_, at) => at !== index))
- }
+ onClick={() => onChange(entries.filter((_, at) => at !== index))}
>
@@ -88,6 +99,68 @@ export function ModelListEditor({ entries, onChange }: ModelListEditorProps) {
/>
+
+
+ {t("provider.capabilities", "Model capabilities")}
+
+
+ {t("provider.inputModalities", "Input modalities")}
+
+ update(index, {
+ inputModalities: event.target.value
+ ? (event.target.value.split(",") as [
+ "text" | "image",
+ ...("text" | "image")[],
+ ])
+ : null,
+ })
+ }
+ >
+ {t("provider.inherit", "Inherit")}
+
+ {t("provider.textOnly", "Text only")}
+
+
+ {t("provider.textImage", "Text and image")}
+
+ {entry.inputModalities &&
+ !["text", "text,image"].includes(
+ entry.inputModalities.join(","),
+ ) ? (
+
+ {entry.inputModalities.join(", ")}
+
+ ) : null}
+
+
+
+ {t("provider.toolCalling", "Tool calling")}
+
+ update(index, {
+ toolCalling:
+ event.target.value === ""
+ ? null
+ : event.target.value === "true",
+ })
+ }
+ >
+ {t("provider.inherit", "Inherit")}
+ {t("provider.yes", "Yes")}
+ {t("provider.no", "No")}
+
+
+ update(index, { compat })}
+ />
+
))}
+
+ {t("provider.protocol", "API protocol")}
+ {
+ const protocol = event.target.value as ProviderProtocol;
+ const adapter =
+ protocol === "auto"
+ ? draft.adapter
+ : protocol === "anthropic_messages"
+ ? "anthropic"
+ : "openai_compat";
+ onChange({
+ ...draft,
+ protocol,
+ adapter,
+ auth: adapter === "anthropic" ? "api_key" : draft.auth,
+ });
+ }}
+ >
+
+ {t("provider.auto", "Auto · existing routing")}
+
+ OpenAI Chat Completions
+ OpenAI Responses
+ Anthropic Messages
+
+
+
+ {t("provider.auth", "Authentication")}
+
+ onChange({
+ ...draft,
+ auth: event.target.value as Draft["auth"],
+ apiKey: "",
+ clearApiKey: false,
+ apiKeyEnv: event.target.value === "oauth" ? "" : draft.apiKeyEnv,
+ })
+ }
+ >
+ {t("provider.apiKey", "API key")}
+ {draft.template === "openrouter" ? (
+
+ {t("provider.signInAuth", "Sign in with OpenRouter")}
+
+ ) : null}
+
+ {t("provider.noAuth", "No authentication")}
+
+
+
+ onChange({ ...draft, compat })}
+ />
+
+ );
+}
+
+const fields: {
+ key: keyof ProviderCompat;
+ label: string;
+ values: readonly (string | boolean)[];
+ chatOnly?: boolean;
+}[] = [
+ {
+ key: "tokenLimitField",
+ label: "Token limit field",
+ values: ["max_tokens", "max_completion_tokens"],
+ chatOnly: true,
+ },
+ { key: "temperature", label: "Send temperature", values: [true, false] },
+ {
+ key: "systemRole",
+ label: "Instruction role",
+ values: ["system", "developer", "user"],
+ chatOnly: true,
+ },
+ {
+ key: "reasoningField",
+ label: "Reasoning parameter",
+ values: ["reasoning_effort", "reasoning", "omit"],
+ },
+ {
+ key: "reasoningContent",
+ label: "Reasoning history",
+ values: ["preserve", "empty", "omit"],
+ chatOnly: true,
+ },
+ {
+ key: "toolMessageName",
+ label: "Send tool result name",
+ values: [true, false],
+ chatOnly: true,
+ },
+ {
+ key: "parallelToolCalls",
+ label: "Parallel tool calls",
+ values: [true, false],
+ },
+];
+
+export function CompatEditor({
+ protocol,
+ value,
+ onChange,
+}: {
+ protocol: ProviderProtocol;
+ value: ProviderCompat;
+ onChange(value: ProviderCompat): void;
+}) {
+ const { t } = useTranslation();
+ const explicit = protocol !== "auto";
+ return (
+
+ {t("provider.compat", "Protocol compatibility")}
+ {!explicit ? (
+
+ {t(
+ "provider.compatExplicit",
+ "Choose an explicit protocol to set compatibility overrides.",
+ )}
+
+ ) : (
+
+ {fields
+ .filter(
+ (field) =>
+ value[field.key] != null ||
+ (protocol === "anthropic_messages"
+ ? field.key === "temperature"
+ : protocol === "openai_chat" || !field.chatOnly),
+ )
+ .map((field) => (
+
+ {t(`provider.compat.${field.key}`, field.label)}
+ {
+ const next = { ...value };
+ const selected = field.values.find(
+ (candidate) => String(candidate) === event.target.value,
+ );
+ if (selected === undefined) delete next[field.key];
+ else Object.assign(next, { [field.key]: selected });
+ onChange(next);
+ }}
+ >
+ {t("provider.inherit", "Inherit")}
+ {field.values.map((candidate) => (
+
+ {typeof candidate === "boolean"
+ ? candidate
+ ? t("provider.yes", "Yes")
+ : t("provider.no", "No")
+ : candidate}
+
+ ))}
+
+
+ ))}
+
+ )}
+ {Object.keys(value).length > 0 ? (
+ onChange({})}>
+ {t("provider.resetCompat", "Clear compatibility overrides")}
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/settings/ProviderLogin.tsx b/desktop/src/features/settings/ProviderLogin.tsx
new file mode 100644
index 000000000..bf144d0f1
--- /dev/null
+++ b/desktop/src/features/settings/ProviderLogin.tsx
@@ -0,0 +1,136 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import type {
+ ConnectionInfo,
+ ProviderLoginFlow,
+} from "../../generated/app-server";
+import type { ConnectionCatalogController } from "./useConnectionCatalog";
+import { confirmAction } from "../../platform/confirmAction";
+import styles from "./ConnectionSettings.module.css";
+
+const pending = (flow: ProviderLoginFlow | null) =>
+ flow && ["starting", "pending", "exchanging"].includes(flow.status);
+
+export function ProviderLogin({
+ connection,
+ controller,
+}: {
+ connection: ConnectionInfo;
+ controller: ConnectionCatalogController;
+}) {
+ const { t } = useTranslation();
+ const [flow, setFlow] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const { pollLogin, reload } = controller;
+ useEffect(() => {
+ if (!pending(flow) || !flow) return;
+ let cancelled = false;
+ const timer = setTimeout(() => {
+ void pollLogin(flow.flowId)
+ .then(async (next) => {
+ if (cancelled) return;
+ setFlow(next);
+ if (next.status === "authenticated") await reload();
+ })
+ .catch((cause) => {
+ if (!cancelled) setError(String(cause));
+ });
+ }, 1000);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [flow, pollLogin, reload]);
+ const run = async (operation: () => Promise) => {
+ setBusy(true);
+ setError(null);
+ try {
+ await operation();
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : String(cause));
+ } finally {
+ setBusy(false);
+ }
+ };
+ return (
+
+ {connection.accountId ? (
+
+ {t("provider.account", "Account")}: {connection.accountId}
+
+ ) : null}
+
+ {t(
+ "provider.loginExplanation",
+ "OpenRouter login supplies a user-controlled API key. No refresh token is issued. Sign in on the machine running DeepCode.",
+ )}
+
+
+
+ void run(async () => setFlow(await controller.login(connection.id)))
+ }
+ >
+ {t("provider.signInAuth", "Sign in with OpenRouter")}
+
+ {pending(flow) && flow ? (
+
+ void run(async () =>
+ setFlow(await controller.cancelLogin(flow.flowId)),
+ )
+ }
+ >
+ {t("provider.cancelLogin", "Cancel login")}
+
+ ) : null}
+ {connection.accountId ? (
+
+ void run(async () => {
+ if (
+ !(await confirmAction(
+ t(
+ "provider.disconnectConfirm",
+ "Disconnect this account locally? Its next model request will stop. To revoke the key remotely, use OpenRouter's key settings.",
+ ),
+ { confirmLabel: t("provider.disconnect", "Disconnect") },
+ ))
+ )
+ return;
+ await controller.logout(connection.id);
+ setFlow(null);
+ await reload();
+ })
+ }
+ >
+ {t("provider.disconnect", "Disconnect")}
+
+ ) : null}
+
+ {t("provider.manageKeys", "Manage remote keys")}
+
+
+ {flow?.authorizationUrl ? (
+
+ {t("provider.openLogin", "Open sign-in page")}
+
+ ) : null}
+ {flow ? (
+
{t(`provider.login.${flow.status}`, flow.status)}
+ ) : null}
+ {error || flow?.error ?
{error || flow?.error}
: null}
+
+ );
+}
diff --git a/desktop/src/features/settings/SettingsDialog.tsx b/desktop/src/features/settings/SettingsDialog.tsx
index 766a28c52..92d2c5033 100644
--- a/desktop/src/features/settings/SettingsDialog.tsx
+++ b/desktop/src/features/settings/SettingsDialog.tsx
@@ -17,7 +17,7 @@ import type {
SettingsSnapshot,
} from "../../generated/app-server";
import { useEscapeLayer } from "../../app/escapeLayer";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import {
SETTINGS_SECTIONS,
type SettingsSectionId,
@@ -25,7 +25,7 @@ import {
import styles from "./SettingsDialog.module.css";
interface SettingsDialogProps {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
settings: SettingsSnapshot | null;
busy: boolean;
@@ -135,7 +135,7 @@ export function SettingsDialog({
onClick={() => void openConfigFile()}
>
- {t("settings.openConfig", "Open configuration file")}
+ {runtime.host?.nativeOpen === false ? "Copy configuration path" : t("settings.openConfig", "Open configuration file")}
{
+ const entry = {
+ id: "model",
+ inputModalities: ["text"] as ["text"],
+ toolCalling: false,
+ reasoningEfforts: false as const,
+ compat: { temperature: false },
+ };
+ const value = connectionMutation({
+ ...emptyDraft,
+ id: "local",
+ template: "custom",
+ protocol: "openai_chat",
+ auth: "none",
+ compat: { systemRole: "developer" },
+ manualModels: [entry, { id: "simple" }],
+ });
+ expect(value.manualModels).toEqual([entry, "simple"]);
+ expect(value).toMatchObject({
+ protocol: "openai_chat",
+ auth: "none",
+ compat: { systemRole: "developer" },
+ });
+ expect(value).not.toHaveProperty("apiKey");
+});
diff --git a/desktop/src/features/settings/connectionDraft.ts b/desktop/src/features/settings/connectionDraft.ts
new file mode 100644
index 000000000..67d281d80
--- /dev/null
+++ b/desktop/src/features/settings/connectionDraft.ts
@@ -0,0 +1,87 @@
+import type {
+ ManualModelEntry,
+ ProviderCompat,
+ ProviderProtocol,
+ ProviderUpsertParams,
+} from "../../generated/app-server";
+
+export interface Draft {
+ id: string;
+ label: string;
+ template: string;
+ adapter: "openai_compat" | "anthropic";
+ protocol: ProviderProtocol;
+ auth: "api_key" | "none" | "oauth";
+ compat: ProviderCompat;
+ apiBase: string;
+ /** Environment-variable reference — the advanced alternative to a
+ * stored key (the write-only input is the primary path, dsh style). */
+ apiKeyEnv: string;
+ apiKey: string;
+ clearApiKey: boolean;
+ modelCatalog: "auto" | "openrouter" | "openai" | "anthropic" | "manual";
+ manualModels: ManualModelEntry[];
+ /** True when the launch environment currently provides this key: it
+ * outranks a pasted key, so the form must say so instead of letting a
+ * paste silently lose. */
+ environmentShadows: boolean;
+ shadowingEnvName: string;
+}
+
+export const emptyDraft: Draft = {
+ id: "",
+ label: "",
+ template: "",
+ adapter: "openai_compat",
+ protocol: "auto",
+ auth: "api_key",
+ compat: {},
+ apiBase: "",
+ apiKeyEnv: "",
+ apiKey: "",
+ clearApiKey: false,
+ modelCatalog: "auto",
+ manualModels: [],
+ environmentShadows: false,
+ shadowingEnvName: "",
+};
+
+export function connectionMutation(
+ draft: Draft,
+): ProviderUpsertParams["connection"] {
+ const connection: ProviderUpsertParams["connection"] = {
+ id: draft.id.trim().toLowerCase(),
+ label: draft.label.trim() || draft.id.trim(),
+ template: draft.template,
+ adapter: draft.adapter,
+ protocol: draft.protocol,
+ auth: draft.auth,
+ compat: draft.compat,
+ apiBase: draft.apiBase.trim() || null,
+ apiKeyEnv: draft.apiKeyEnv.trim() || null,
+ modelCatalog: draft.modelCatalog,
+ manualModels: draft.manualModels
+ .map((entry) => ({ ...entry, id: entry.id.trim() }))
+ .filter((entry) => entry.id)
+ .map((entry) => (hasDeclarations(entry) ? entry : entry.id)),
+ enabled: true,
+ };
+ if (draft.apiKey.trim()) {
+ connection.apiKey = draft.apiKey.trim();
+ }
+ if (draft.clearApiKey) connection.clearApiKey = true;
+
+ return connection;
+}
+
+export function hasDeclarations(entry: ManualModelEntry): boolean {
+ return (
+ entry.label != null ||
+ entry.contextWindow != null ||
+ entry.maxOutputTokens != null ||
+ entry.reasoningEfforts != null ||
+ entry.inputModalities != null ||
+ entry.toolCalling != null ||
+ Object.keys(entry.compat ?? {}).length > 0
+ );
+}
diff --git a/desktop/src/features/settings/sections/DiagnosticsCard.tsx b/desktop/src/features/settings/sections/DiagnosticsCard.tsx
index fdef7af8b..c86f2b4c4 100644
--- a/desktop/src/features/settings/sections/DiagnosticsCard.tsx
+++ b/desktop/src/features/settings/sections/DiagnosticsCard.tsx
@@ -5,7 +5,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Project } from "../../../generated/app-server";
-import type { DesktopRuntime } from "../../../rpc/contracts";
+import type { ClientRuntime } from "../../../rpc/contracts";
import { useDiagnostics } from "../useDiagnostics";
import styles from "../../management/ManagementWorkspace.module.css";
@@ -13,7 +13,7 @@ export function DiagnosticsCard({
runtime,
project,
}: {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
}) {
const { t } = useTranslation();
diff --git a/desktop/src/features/settings/sections/GeneralSection.tsx b/desktop/src/features/settings/sections/GeneralSection.tsx
index b86eb0c4a..5e45f7778 100644
--- a/desktop/src/features/settings/sections/GeneralSection.tsx
+++ b/desktop/src/features/settings/sections/GeneralSection.tsx
@@ -11,6 +11,7 @@ import { LanguageCard } from "./LanguageCard";
import { DefaultPresetCard } from "./DefaultPresetCard";
import { DiagnosticsCard } from "./DiagnosticsCard";
import { PermissionCard } from "./PermissionCard";
+import { ServiceCard } from "./ServiceCard";
import { UpdatesCard } from "./UpdatesCard";
import styles from "../../management/ManagementWorkspace.module.css";
@@ -28,6 +29,7 @@ export function GeneralSection(props: SettingsSectionProps) {
+
diff --git a/desktop/src/features/settings/sections/ServiceCard.test.tsx b/desktop/src/features/settings/sections/ServiceCard.test.tsx
new file mode 100644
index 000000000..2660b6acd
--- /dev/null
+++ b/desktop/src/features/settings/sections/ServiceCard.test.tsx
@@ -0,0 +1,61 @@
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ClientRuntime } from "../../../rpc/contracts";
+import { ServiceCard } from "./ServiceCard";
+
+vi.mock("../../../platform/confirmAction", () => ({
+ confirmAction: vi.fn(async () => true),
+}));
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
+}));
+afterEach(cleanup);
+
+function runtime(shared: boolean) {
+ return {
+ status: vi.fn(async () => ({
+ serverInfo: shared
+ ? { serviceInfo: { shutdownScope: "connection" } }
+ : {},
+ })),
+ serviceStatus: vi.fn(async () => ({
+ phase: "ready",
+ activeTurns: 1,
+ queuedTurns: 2,
+ terminals: 0,
+ })),
+ stopService: vi.fn(async () => {}),
+ };
+}
+
+describe("native service controls", () => {
+ it("does not expose global stop for an embedded host", async () => {
+ const host = runtime(false);
+ render( );
+ await waitFor(() => expect(host.status).toHaveBeenCalledOnce());
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(host.serviceStatus).not.toHaveBeenCalled();
+ });
+
+ it("reads current activity and stops only after an explicit action", async () => {
+ const host = runtime(true);
+ const view = render(
+ ,
+ );
+ const button = await screen.findByRole("button", {
+ name: "Stop background service",
+ });
+ expect(host.stopService).not.toHaveBeenCalled();
+ fireEvent.click(button);
+ await waitFor(() => expect(host.stopService).toHaveBeenCalledOnce());
+ expect(host.serviceStatus).toHaveBeenCalledTimes(2);
+ view.unmount();
+ expect(host.stopService).toHaveBeenCalledOnce();
+ });
+});
diff --git a/desktop/src/features/settings/sections/ServiceCard.tsx b/desktop/src/features/settings/sections/ServiceCard.tsx
new file mode 100644
index 000000000..a7172c5bd
--- /dev/null
+++ b/desktop/src/features/settings/sections/ServiceCard.tsx
@@ -0,0 +1,111 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import type { ClientRuntime } from "../../../rpc/contracts";
+import { confirmAction } from "../../../platform/confirmAction";
+import styles from "../../management/ManagementWorkspace.module.css";
+
+type Activity = {
+ phase: string;
+ activeTurns: number;
+ queuedTurns: number;
+ terminals: number;
+};
+
+export function ServiceCard({ runtime }: { runtime: ClientRuntime }) {
+ const { t } = useTranslation();
+ const [shared, setShared] = useState(false);
+ const [activity, setActivity] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ useEffect(() => {
+ let cancelled = false;
+ if (!runtime.serviceStatus || !runtime.stopService) return;
+ void runtime
+ .status()
+ .then(async (status) => {
+ if (
+ cancelled ||
+ status.serverInfo?.serviceInfo?.shutdownScope !== "connection"
+ )
+ return;
+ setShared(true);
+ const current = await runtime.serviceStatus!();
+ if (!cancelled) setActivity(current);
+ })
+ .catch((cause) => {
+ if (!cancelled) setError(String(cause));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [runtime]);
+ if (!shared) return null;
+ const stop = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ const current = await runtime.serviceStatus!();
+ setActivity(current);
+ if (
+ !(await confirmAction(
+ t(
+ "service.stopConfirm",
+ "Stop the shared service? {{active}} active tasks, {{queued}} queued tasks, {{terminals}} terminals. It will wait up to 10 seconds; if work remains active, the service stays running.",
+ {
+ active: current.activeTurns,
+ queued: current.queuedTurns,
+ terminals: current.terminals,
+ },
+ ),
+ { confirmLabel: t("service.stop", "Stop background service") },
+ ))
+ )
+ return;
+ await runtime.stopService!();
+ setActivity({ ...current, phase: "stopped" });
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : String(cause));
+ } finally {
+ setBusy(false);
+ }
+ };
+ return (
+
+
+ {t("service.title", "Background service")}
+ void stop()}
+ >
+ {t("service.stop", "Stop background service")}
+
+
+
+ {t(
+ "service.detach",
+ "Closing Desktop disconnects this window. Tasks and scheduled work continue in the shared service.",
+ )}
+
+ {activity && (
+
+ {t(
+ "service.activity",
+ "{{phase}} · {{active}} active tasks · {{queued}} queued · {{terminals}} terminals",
+ {
+ phase: activity.phase,
+ active: activity.activeTurns,
+ queued: activity.queuedTurns,
+ terminals: activity.terminals,
+ },
+ )}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/desktop/src/features/settings/sections/UpdatesCard.tsx b/desktop/src/features/settings/sections/UpdatesCard.tsx
index 0aad2db6f..b2eb1bf5e 100644
--- a/desktop/src/features/settings/sections/UpdatesCard.tsx
+++ b/desktop/src/features/settings/sections/UpdatesCard.tsx
@@ -5,7 +5,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import type {
- DesktopRuntime,
+ ClientRuntime,
DesktopUpdateInfo,
DesktopUpdateProgress,
} from "../../../rpc/contracts";
@@ -13,7 +13,7 @@ import styles from "../../management/ManagementWorkspace.module.css";
type UpdateState = "idle" | "checking" | "current" | "available" | "installing";
-export function UpdatesCard({ runtime }: { runtime: DesktopRuntime }) {
+export function UpdatesCard({ runtime }: { runtime: ClientRuntime }) {
const { t } = useTranslation();
const [updateInfo, setUpdateInfo] = useState(null);
const [updateState, setUpdateState] = useState("idle");
@@ -21,6 +21,10 @@ export function UpdatesCard({ runtime }: { runtime: DesktopRuntime }) {
useState(null);
const [updateError, setUpdateError] = useState(null);
+ if (runtime.host?.updates === false) return
+ Service updates Update DeepCode on the service machine, then open a fresh link with deepcode web. Reload this page after updating.
+ ;
+
const checkForUpdate = async () => {
setUpdateState("checking");
setUpdateInfo(null);
diff --git a/desktop/src/features/settings/settingsSections.tsx b/desktop/src/features/settings/settingsSections.tsx
index 3ed4f58ad..68fb9113e 100644
--- a/desktop/src/features/settings/settingsSections.tsx
+++ b/desktop/src/features/settings/settingsSections.tsx
@@ -15,7 +15,7 @@ import type {
Project,
SettingsSnapshot,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import { GeneralSection } from "./sections/GeneralSection";
import { ModelsSection } from "./sections/ModelsSection";
import { PluginsSection } from "./sections/PluginsSection";
@@ -25,7 +25,7 @@ export type SettingsSectionId = "general" | "models" | "plugins" | "agent-preset
/** Everything a section may need; each uses the subset it cares about. */
export interface SettingsSectionProps {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
project: Project | null;
settings: SettingsSnapshot | null;
busy: boolean;
diff --git a/desktop/src/features/settings/useConnectionCatalog.ts b/desktop/src/features/settings/useConnectionCatalog.ts
index 3eaa7da8a..822b61dd3 100644
--- a/desktop/src/features/settings/useConnectionCatalog.ts
+++ b/desktop/src/features/settings/useConnectionCatalog.ts
@@ -6,18 +6,29 @@ import type {
ConnectionCatalogResult,
ModelCatalogResult,
ProviderTestResult,
+ ProviderTestParams,
+ ProviderLoginFlow,
+ ProviderLogoutResult,
ProviderUpsertParams,
} from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
export interface ConnectionCatalogController {
catalog: ConnectionCatalogResult | null;
loading: boolean;
error: string | null;
reload(): Promise;
+ login(connectionId: string): Promise;
+ pollLogin(flowId: string): Promise;
+ cancelLogin(flowId: string): Promise;
+ logout(connectionId: string): Promise;
upsert(connection: ProviderUpsertParams["connection"]): Promise;
remove(connectionId: string): Promise;
- test(connectionId: string, model?: string): Promise;
+ test(
+ connectionId: string,
+ model?: string,
+ options?: Pick,
+ ): Promise;
models(connectionId: string, refresh?: boolean): Promise;
/** Probe an endpoint AS SHOWN in an editor form; nothing is stored. */
discover(
@@ -26,7 +37,7 @@ export interface ConnectionCatalogController {
}
export function useConnectionCatalog(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
projectId: string | null,
): ConnectionCatalogController {
const [state, setState] = useState<{
@@ -138,10 +149,15 @@ export function useConnectionCatalog(
);
const test = useCallback(
- async (connectionId: string, model?: string) => {
+ async (
+ connectionId: string,
+ model?: string,
+ options?: Pick,
+ ) => {
try {
const result = await runtime.request("provider/test", {
connectionId,
+ ...options,
...(projectId ? { projectId } : {}),
...(model ? { model } : {}),
});
@@ -174,8 +190,33 @@ export function useConnectionCatalog(
[projectId, runtime],
);
+ const login = useCallback(
+ (connectionId: string) =>
+ runtime.request("provider/login/start", {
+ connectionId,
+ openBrowser: true,
+ }),
+ [runtime],
+ );
+ const pollLogin = useCallback(
+ (flowId: string) => runtime.request("provider/login/poll", { flowId }),
+ [runtime],
+ );
+ const cancelLogin = useCallback(
+ (flowId: string) => runtime.request("provider/login/cancel", { flowId }),
+ [runtime],
+ );
+ const logout = useCallback(
+ (connectionId: string) =>
+ runtime.request("provider/logout", { connectionId }),
+ [runtime],
+ );
const currentProject = state.projectId === projectId;
return {
+ login,
+ pollLogin,
+ cancelLogin,
+ logout,
catalog: currentProject ? state.catalog : null,
loading: currentProject ? state.loading : true,
error: currentProject ? state.error : null,
diff --git a/desktop/src/features/settings/useDiagnostics.ts b/desktop/src/features/settings/useDiagnostics.ts
index 6471ee250..4db1ec286 100644
--- a/desktop/src/features/settings/useDiagnostics.ts
+++ b/desktop/src/features/settings/useDiagnostics.ts
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { DiagnosticsSnapshot } from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
export function useDiagnostics(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
projectId: string | null,
) {
const [diagnostics, setDiagnostics] = useState(null);
diff --git a/desktop/src/features/skills/useSkillCatalog.test.tsx b/desktop/src/features/skills/useSkillCatalog.test.tsx
index 9a5563053..442fa49e6 100644
--- a/desktop/src/features/skills/useSkillCatalog.test.tsx
+++ b/desktop/src/features/skills/useSkillCatalog.test.tsx
@@ -9,7 +9,7 @@ import type {
} from "../../generated/app-server";
import type {
AnyRpcNotification,
- DesktopRuntime,
+ ClientRuntime,
RpcMethod,
} from "../../rpc/contracts";
import { useSkillCatalog } from "./useSkillCatalog";
@@ -96,7 +96,7 @@ function Consumer({
runtime,
label,
}: {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
label: string;
}) {
const state = useSkillCatalog(runtime, projectId);
@@ -105,7 +105,7 @@ function Consumer({
test("shares one catalog request across Desktop consumers", async () => {
const backend = new CatalogRuntime();
- const runtime = backend as unknown as DesktopRuntime;
+ const runtime = backend as unknown as ClientRuntime;
render(
<>
@@ -120,7 +120,7 @@ test("shares one catalog request across Desktop consumers", async () => {
test("refreshes every consumer after skills.changed", async () => {
const backend = new CatalogRuntime();
- const runtime = backend as unknown as DesktopRuntime;
+ const runtime = backend as unknown as ClientRuntime;
const view = render(
<>
diff --git a/desktop/src/features/skills/useSkillCatalog.ts b/desktop/src/features/skills/useSkillCatalog.ts
index 7c0bdeafe..547cd8aca 100644
--- a/desktop/src/features/skills/useSkillCatalog.ts
+++ b/desktop/src/features/skills/useSkillCatalog.ts
@@ -6,7 +6,7 @@ import type {
} from "../../generated/app-server";
import type {
AnyRpcNotification,
- DesktopRuntime,
+ ClientRuntime,
} from "../../rpc/contracts";
interface SkillCatalogState extends SkillCatalogResult {
@@ -33,7 +33,7 @@ const emptyCatalog: SkillCatalogState = {
error: null,
};
-const stores = new WeakMap();
+const stores = new WeakMap();
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
@@ -49,7 +49,7 @@ class SkillCatalogStore {
private notificationGeneration = 0;
private unsubscribeNotifications: (() => void) | null = null;
- constructor(private readonly runtime: DesktopRuntime) {}
+ constructor(private readonly runtime: ClientRuntime) {}
snapshot(projectId: string | null): SkillCatalogState {
return projectId ? this.entry(projectId).state : emptyCatalog;
@@ -179,7 +179,7 @@ class SkillCatalogStore {
}
}
-function storeFor(runtime: DesktopRuntime): SkillCatalogStore {
+function storeFor(runtime: ClientRuntime): SkillCatalogStore {
let store = stores.get(runtime);
if (!store) {
store = new SkillCatalogStore(runtime);
@@ -189,7 +189,7 @@ function storeFor(runtime: DesktopRuntime): SkillCatalogStore {
}
export function useSkillCatalog(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
projectId: string | null,
) {
const store = useMemo(() => storeFor(runtime), [runtime]);
diff --git a/desktop/src/features/workbench/TerminalPanel.test.tsx b/desktop/src/features/workbench/TerminalPanel.test.tsx
new file mode 100644
index 000000000..f260fc8b5
--- /dev/null
+++ b/desktop/src/features/workbench/TerminalPanel.test.tsx
@@ -0,0 +1,247 @@
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type {
+ AnyRpcNotification,
+ ClientRuntime,
+ SidecarStatus,
+} from "../../rpc/contracts";
+import { TerminalPanel } from "./TerminalPanel";
+
+const state = vi.hoisted(() => ({
+ instances: [] as Array<{
+ text: string;
+ input: (data: string) => void;
+ disposed: boolean;
+ }>,
+}));
+vi.mock("@xterm/xterm", () => ({
+ Terminal: class {
+ text = "";
+ cols = 80;
+ rows = 24;
+ disposed = false;
+ input = (data: string) => {
+ void data;
+ };
+ constructor() {
+ state.instances.push(this);
+ }
+ loadAddon() {}
+ open() {}
+ focus() {}
+ clear() {
+ this.text = "";
+ }
+ reset() {
+ this.text = "";
+ }
+ write(data: string, done?: () => void) {
+ this.text += data;
+ done?.();
+ }
+ onData(callback: (data: string) => void) {
+ this.input = callback;
+ return { dispose() {} };
+ }
+ dispose() {
+ this.disposed = true;
+ }
+ },
+}));
+vi.mock("@xterm/addon-fit", () => ({
+ FitAddon: class {
+ fit() {}
+ },
+}));
+
+function harness(initial = true) {
+ const info = {
+ terminalId: "term_test",
+ threadId: "thread-1",
+ pid: 42,
+ columns: 80,
+ rows: 24,
+ workspacePath: "/tmp",
+ };
+ const backend = {
+ exists: initial,
+ text: "startup\r\n",
+ exited: false,
+ phase: "ready" as SidecarStatus["phase"],
+ };
+ const notifications = new Set<(value: AnyRpcNotification) => void>();
+ const statuses = new Set<(value: SidecarStatus) => void>();
+ const status = () =>
+ ({
+ phase: backend.phase,
+ serverInfo: {
+ capabilities: { methods: ["terminal/list", "terminal/read"] },
+ },
+ }) as SidecarStatus;
+ const request = vi.fn(
+ async (method: string, params: { offset?: number; data?: string }) => {
+ if (method === "terminal/list")
+ return {
+ terminals: backend.exists
+ ? [
+ {
+ terminal: info,
+ exited: backend.exited,
+ exitCode: backend.exited ? 0 : null,
+ },
+ ]
+ : [],
+ };
+ if (method === "terminal/create") {
+ backend.exists = true;
+ return { terminal: info };
+ }
+ if (method === "terminal/close") {
+ backend.exited = true;
+ return { accepted: true };
+ }
+ if (method === "terminal/write")
+ return { written: params.data?.length ?? 0 };
+ if (method === "terminal/read") {
+ const offset = params.offset ?? 0;
+ return {
+ terminalId: info.terminalId,
+ threadId: info.threadId,
+ data: backend.text.slice(offset),
+ offset,
+ nextOffset: backend.text.length,
+ availableFrom: 0,
+ headOffset: backend.text.length,
+ hasMore: false,
+ truncated: false,
+ exited: backend.exited,
+ exitCode: backend.exited ? 0 : null,
+ };
+ }
+ throw new Error(method);
+ },
+ );
+ const runtime = {
+ request,
+ status: async () => status(),
+ onNotification: async (listener: (value: AnyRpcNotification) => void) => {
+ notifications.add(listener);
+ return () => notifications.delete(listener);
+ },
+ onStatus: async (listener: (value: SidecarStatus) => void) => {
+ statuses.add(listener);
+ return () => statuses.delete(listener);
+ },
+ } as unknown as ClientRuntime;
+ const props = {
+ runtime,
+ threadId: info.threadId,
+ enabled: true,
+ active: true,
+ };
+ return { props, request, backend, statuses, notifications, status };
+}
+
+beforeEach(() => {
+ state.instances.length = 0;
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+});
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe("TerminalPanel recovery", () => {
+ it("reattaches an existing terminal and detaches without terminating it", async () => {
+ const test = harness();
+ const view = render( );
+ await screen.findByText("PID 42");
+ await waitFor(() => expect(state.instances[0].text).toBe("startup\r\n"));
+ view.unmount();
+ expect(state.instances[0].disposed).toBe(true);
+ render( );
+ await waitFor(() => expect(state.instances[1]?.text).toBe("startup\r\n"));
+ expect(
+ test.request.mock.calls.some(
+ ([method]) =>
+ method === "terminal/create" || method === "terminal/close",
+ ),
+ ).toBe(false);
+ });
+
+ it("recovers startup and reconnect output without resending terminal input", async () => {
+ const test = harness(false);
+ const view = render( );
+ await waitFor(() =>
+ expect(
+ (screen.getByText("Start terminal") as HTMLButtonElement).disabled,
+ ).toBe(false),
+ );
+ fireEvent.click(screen.getByText("Start terminal"));
+ await waitFor(() => expect(state.instances[0].text).toBe("startup\r\n"));
+ act(() => state.instances[0].input("echo test\r"));
+ test.backend.phase = "stopped";
+ act(() => test.statuses.forEach((receive) => receive(test.status())));
+ act(() => state.instances[0].input("must not send\r"));
+ test.backend.text += "after reconnect\r\n";
+ test.backend.phase = "ready";
+ act(() => test.statuses.forEach((receive) => receive(test.status())));
+ await waitFor(() =>
+ expect(state.instances[0].text).toBe(test.backend.text),
+ );
+ expect(
+ test.request.mock.calls.filter(([method]) => method === "terminal/write"),
+ ).toHaveLength(1);
+ expect(
+ test.request.mock.calls.filter(
+ ([method]) => method === "terminal/create",
+ ),
+ ).toHaveLength(1);
+ fireEvent.click(screen.getByText("Close"));
+ await screen.findByText("Exited");
+ view.unmount();
+ expect(
+ test.request.mock.calls.filter(([method]) => method === "terminal/close"),
+ ).toHaveLength(1);
+ });
+
+ it("recovers a dropped final output and exit on overflow warning", async () => {
+ const test = harness();
+ render( );
+ await waitFor(() =>
+ expect(state.instances[0]?.text).toBe(test.backend.text),
+ );
+ test.backend.text += "final output\r\n";
+ test.backend.exited = true;
+ const warning: AnyRpcNotification = {
+ jsonrpc: "2.0",
+ method: "server.warning",
+ params: {
+ replayRequired: true,
+ code: "NOTIFICATION_QUEUE_OVERFLOW",
+ dropped: 1,
+ },
+ };
+ act(() => test.notifications.forEach((receive) => receive(warning)));
+ await screen.findByText("Exited");
+ expect(state.instances[0].text).toBe(
+ test.backend.text + "\r\n[process exited 0]\r\n",
+ );
+ act(() => test.notifications.forEach((receive) => receive(warning)));
+ await act(async () => {});
+ expect(state.instances[0].text.match(/process exited/g)).toHaveLength(1);
+ });
+});
diff --git a/desktop/src/features/workbench/TerminalPanel.tsx b/desktop/src/features/workbench/TerminalPanel.tsx
index ef66acb87..0a21b93b1 100644
--- a/desktop/src/features/workbench/TerminalPanel.tsx
+++ b/desktop/src/features/workbench/TerminalPanel.tsx
@@ -1,13 +1,14 @@
import type { FitAddon } from "@xterm/addon-fit";
import type { Terminal } from "@xterm/xterm";
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import type { TerminalInfo } from "../../generated/app-server";
-import type { DesktopRuntime } from "../../rpc/contracts";
+import type { ClientRuntime } from "../../rpc/contracts";
import styles from "./TerminalPanel.module.css";
+import { TerminalOutputReader } from "./terminalOutputReader";
interface TerminalPanelProps {
- runtime: DesktopRuntime;
+ runtime: ClientRuntime;
threadId: string | null;
enabled: boolean;
active: boolean;
@@ -28,21 +29,76 @@ export function TerminalPanel({
const inputDisposable = useRef<{ dispose(): void } | null>(null);
const initializing = useRef(false);
const disposed = useRef(false);
+ const recovery = useRef(null);
+ const recoverable = useRef(false);
+ const generation = useRef(0);
const [terminalInfo, setTerminalInfo] = useState(null);
const [ended, setEnded] = useState(false);
const [error, setError] = useState(null);
const [rendererReady, setRendererReady] = useState(false);
+ const [connected, updateConnected] = useState(false);
+ const connectionReady = useRef(false);
+ const setConnected = useCallback((ready: boolean) => {
+ connectionReady.current = ready;
+ updateConnected(ready);
+ }, []);
+ const [busy, setBusy] = useState(false);
+
+ const reportError = useCallback((cause: unknown) => {
+ setError(cause instanceof Error ? cause.message : String(cause));
+ }, []);
+
+ const finish = useCallback((code: number | null) => {
+ terminal.current?.write(`\r\n[process exited ${code ?? "unknown"}]\r\n`);
+ setEnded(true);
+ info.current = null;
+ setTerminalInfo(null);
+ }, []);
+
+ const attach = useCallback(
+ async (current: TerminalInfo) => {
+ recovery.current?.stop();
+ recovery.current = null;
+ terminal.current?.reset();
+ info.current = current;
+ setTerminalInfo(current);
+ setEnded(false);
+ if (!recoverable.current) return;
+ const reader = new TerminalOutputReader(
+ runtime,
+ current,
+ (data) =>
+ new Promise((resolve) => {
+ if (terminal.current) terminal.current.write(data, resolve);
+ else resolve();
+ }),
+ () => {
+ terminal.current?.reset();
+ terminal.current?.write(
+ "[Earlier terminal output is no longer available]\r\n",
+ );
+ },
+ finish,
+ reportError,
+ );
+ recovery.current = reader;
+ await reader.recover();
+ },
+ [finish, reportError, runtime],
+ );
useEffect(() => {
- if (!active || !host.current || terminal.current || initializing.current) return;
+ if (!active || !host.current || terminal.current || initializing.current)
+ return;
initializing.current = true;
const hostElement = host.current;
void (async () => {
- const [{ Terminal: XTerm }, { FitAddon: XTermFitAddon }] = await Promise.all([
- import("@xterm/xterm"),
- import("@xterm/addon-fit"),
- import("@xterm/xterm/css/xterm.css"),
- ]);
+ const [{ Terminal: XTerm }, { FitAddon: XTermFitAddon }] =
+ await Promise.all([
+ import("@xterm/xterm"),
+ import("@xterm/addon-fit"),
+ import("@xterm/xterm/css/xterm.css"),
+ ]);
if (disposed.current) return;
const instance = new XTerm({
cursorBlink: true,
@@ -70,23 +126,28 @@ export function TerminalPanel({
fit.current = fitAddon;
inputDisposable.current = instance.onData((data) => {
const current = info.current;
- if (current) {
- void runtime.request("terminal/write", {
- threadId: current.threadId,
- terminalId: current.terminalId,
- data,
- });
+ if (current && connectionReady.current) {
+ void runtime
+ .request("terminal/write", {
+ threadId: current.threadId,
+ terminalId: current.terminalId,
+ data,
+ })
+ .catch(reportError);
}
});
observer.current = new ResizeObserver(() => {
- if (!activeRef.current || !info.current) return;
+ if (!activeRef.current || !info.current || !connectionReady.current)
+ return;
fitAddon.fit();
- void runtime.request("terminal/resize", {
- threadId: info.current.threadId,
- terminalId: info.current.terminalId,
- columns: Math.max(20, instance.cols),
- rows: Math.max(5, instance.rows),
- });
+ void runtime
+ .request("terminal/resize", {
+ threadId: info.current.threadId,
+ terminalId: info.current.terminalId,
+ columns: Math.max(20, instance.cols),
+ rows: Math.max(5, instance.rows),
+ })
+ .catch(reportError);
});
observer.current.observe(hostElement);
setRendererReady(true);
@@ -94,12 +155,11 @@ export function TerminalPanel({
initializing.current = false;
setError(cause instanceof Error ? cause.message : String(cause));
});
- }, [active, runtime]);
+ }, [active, reportError, runtime]);
- useEffect(
- () => {
- disposed.current = false;
- return () => {
+ useEffect(() => {
+ disposed.current = false;
+ return () => {
disposed.current = true;
observer.current?.disconnect();
inputDisposable.current?.dispose();
@@ -108,10 +168,8 @@ export function TerminalPanel({
inputDisposable.current = null;
terminal.current = null;
fit.current = null;
- };
- },
- [],
- );
+ };
+ }, []);
useEffect(() => {
activeRef.current = active;
@@ -119,57 +177,135 @@ export function TerminalPanel({
}, [active]);
useEffect(() => {
- let disposed = false;
- let cleanup: () => void = () => undefined;
- void runtime.onNotification((notification) => {
- if (disposed || !threadId) return;
- if (notification.method === "terminal.output") {
- const current = info.current;
- if (
- current &&
- notification.params.threadId === threadId &&
- notification.params.terminalId === current.terminalId
- ) {
- terminal.current?.write(notification.params.data);
- }
+ if (!rendererReady || !threadId) return;
+ terminal.current?.reset();
+ let stopped = false;
+ let refreshVersion = 0;
+ const cleanups: Array<() => void> = [];
+ const currentGeneration = ++generation.current;
+
+ const refresh = async () => {
+ const version = ++refreshVersion;
+ const status = await runtime.status();
+ if (stopped || version !== refreshVersion) return;
+ if (status.phase !== "ready") {
+ setConnected(false);
+ return;
}
- if (
- notification.method === "terminal.exit" &&
- notification.params.terminalId === info.current?.terminalId
- ) {
- terminal.current?.write(
- `\r\n\x1b[90m[process exited ${notification.params.exitCode ?? "unknown"}]\x1b[0m\r\n`,
- );
- setEnded(true);
- info.current = null;
- setTerminalInfo(null);
+ const methods = status.serverInfo?.capabilities.methods ?? [];
+ recoverable.current =
+ methods.includes("terminal/read") && methods.includes("terminal/list");
+ if (recoverable.current) {
+ const { terminals } = await runtime.request("terminal/list", {
+ threadId,
+ });
+ if (stopped || version !== refreshVersion) return;
+ const previous =
+ recovery.current?.terminal.terminalId ?? info.current?.terminalId;
+ const chosen =
+ terminals.find((entry) => entry.terminal.terminalId === previous) ??
+ terminals.filter((entry) => !entry.exited).at(-1) ??
+ terminals.at(-1);
+ if (chosen) {
+ if (
+ recovery.current?.terminal.terminalId === chosen.terminal.terminalId
+ ) {
+ await recovery.current.recover();
+ } else {
+ await attach(chosen.terminal);
+ }
+ } else if (previous) {
+ recovery.current?.stop();
+ recovery.current = null;
+ info.current = null;
+ setTerminalInfo(null);
+ setEnded(true);
+ terminal.current?.write(
+ "\r\n[Terminal session is no longer available]\r\n",
+ );
+ }
}
- }).then((unsubscribe) => {
- if (disposed) unsubscribe();
- else cleanup = unsubscribe;
+ if (!stopped && version === refreshVersion) setConnected(true);
+ };
+
+ const register = async (subscription: Promise<() => void>) => {
+ const cleanup = await subscription;
+ if (stopped) cleanup();
+ else cleanups.push(cleanup);
+ };
+ void (async () => {
+ await register(
+ runtime.onNotification((notification) => {
+ if (stopped) return;
+ const current = recovery.current?.terminal ?? info.current;
+ if (notification.method === "server.warning") {
+ if (notification.params.replayRequired)
+ void recovery.current?.recover().catch(reportError);
+ return;
+ }
+ if (
+ !current ||
+ (notification.method !== "terminal.output" &&
+ notification.method !== "terminal.exit") ||
+ notification.params.threadId !== threadId ||
+ notification.params.terminalId !== current.terminalId
+ )
+ return;
+ if (notification.method === "terminal.output") {
+ if (recovery.current)
+ recovery.current.receive(notification.params.nextOffset ?? 0);
+ else terminal.current?.write(notification.params.data);
+ } else if (recovery.current) {
+ void recovery.current.recover().catch(reportError);
+ } else {
+ finish(notification.params.exitCode);
+ }
+ }),
+ );
+ if (stopped) return;
+ setEnded(false);
+ setError(null);
+ await register(
+ runtime.onStatus((status) => {
+ if (stopped) return;
+ if (status.phase === "ready") void refresh().catch(reportError);
+ else {
+ refreshVersion++;
+ setConnected(false);
+ }
+ }),
+ );
+ if (!stopped) await refresh();
+ })().catch((cause) => {
+ if (!stopped) reportError(cause);
});
+
return () => {
- disposed = true;
- cleanup();
+ stopped = true;
+ generation.current = currentGeneration + 1;
+ for (const cleanup of cleanups) cleanup();
+ recovery.current?.stop();
+ recovery.current = null;
+ info.current = null;
+ setTerminalInfo(null);
+ setConnected(false);
+ setBusy(false);
+ // Detaching a view does not terminate the PTY. Only Close does that.
};
- }, [runtime, threadId]);
-
- useEffect(
- () => () => {
- const current = info.current;
- if (current) {
- void runtime.request("terminal/close", {
- threadId: current.threadId,
- terminalId: current.terminalId,
- });
- info.current = null;
- }
- },
- [runtime, threadId],
- );
+ }, [
+ attach,
+ finish,
+ rendererReady,
+ reportError,
+ runtime,
+ setConnected,
+ threadId,
+ ]);
const start = async () => {
- if (!threadId || !enabled) return;
+ if (!threadId || !enabled || busy || !connected) return;
+ const currentGeneration = generation.current;
+ setBusy(true);
setError(null);
setEnded(false);
terminal.current?.clear();
@@ -180,40 +316,59 @@ export function TerminalPanel({
columns: Math.max(20, terminal.current?.cols ?? 80),
rows: Math.max(5, terminal.current?.rows ?? 24),
});
- info.current = result.terminal;
- setTerminalInfo(result.terminal);
+ if (currentGeneration !== generation.current) return;
+ await attach(result.terminal);
terminal.current?.focus();
} catch (cause) {
- setError(cause instanceof Error ? cause.message : String(cause));
+ if (currentGeneration === generation.current) reportError(cause);
+ } finally {
+ if (currentGeneration === generation.current) setBusy(false);
}
};
const close = async () => {
const current = info.current;
- if (!current) return;
- await runtime.request("terminal/close", {
- threadId: current.threadId,
- terminalId: current.terminalId,
- });
- info.current = null;
- setTerminalInfo(null);
- setEnded(true);
- terminal.current?.write("\r\n\x1b[90m[terminal closed]\x1b[0m\r\n");
+ if (!current || busy || !connected) return;
+ const currentGeneration = generation.current;
+ setBusy(true);
+ try {
+ await runtime.request("terminal/close", {
+ threadId: current.threadId,
+ terminalId: current.terminalId,
+ });
+ if (currentGeneration !== generation.current) return;
+ if (recovery.current) await recovery.current.recover();
+ else finish(null);
+ } catch (cause) {
+ if (currentGeneration === generation.current) reportError(cause);
+ } finally {
+ if (currentGeneration === generation.current) setBusy(false);
+ }
};
return (
-
{terminalInfo ? `PID ${terminalInfo.pid}` : ended ? "Exited" : "No session"}
+
+ {terminalInfo
+ ? `PID ${terminalInfo.pid}`
+ : ended
+ ? "Exited"
+ : "No session"}
+
{terminalInfo ? (
-
void close()}>
+ void close()}
+ >
Close
) : (
void start()}
- disabled={!enabled || !rendererReady}
+ disabled={!enabled || !rendererReady || !connected || busy}
>
Start terminal
diff --git a/desktop/src/features/workbench/terminalOutputReader.test.ts b/desktop/src/features/workbench/terminalOutputReader.test.ts
new file mode 100644
index 000000000..21b7801be
--- /dev/null
+++ b/desktop/src/features/workbench/terminalOutputReader.test.ts
@@ -0,0 +1,221 @@
+import { describe, expect, it, vi } from "vitest";
+import type {
+ MethodParams,
+ MethodResults,
+ TerminalInfo,
+} from "../../generated/app-server";
+import type { RpcMethod, RpcTransport } from "../../rpc/contracts";
+import { TerminalOutputReader } from "./terminalOutputReader";
+
+const terminal: TerminalInfo = {
+ terminalId: "term_test",
+ threadId: "thread-1",
+ pid: 1,
+ rows: 24,
+ columns: 80,
+ workspacePath: "/tmp",
+};
+type Page = MethodResults["terminal/read"];
+function page(offset: number, data: string, other: Partial = {}): Page {
+ const next = offset + new TextEncoder().encode(data).length;
+ return {
+ threadId: terminal.threadId,
+ terminalId: terminal.terminalId,
+ offset,
+ data,
+ nextOffset: next,
+ headOffset: next,
+ availableFrom: 0,
+ hasMore: false,
+ truncated: false,
+ exited: false,
+ exitCode: null,
+ ...other,
+ };
+}
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((yes) => {
+ resolve = yes;
+ });
+ return { resolve, promise };
+}
+class Runtime implements RpcTransport {
+ calls: MethodParams["terminal/read"][] = [];
+ constructor(
+ private read: (params: MethodParams["terminal/read"]) => Promise,
+ ) {}
+ async request(
+ method: M,
+ params: MethodParams[M],
+ ): Promise {
+ // Any create/write/close during recovery fails the test.
+ expect(method).toBe("terminal/read");
+ const input = params as MethodParams["terminal/read"];
+ this.calls.push(input);
+ return this.read(input) as Promise;
+ }
+}
+function reader(
+ runtime: Runtime,
+ write = vi.fn(async (data: string): Promise => {
+ void data;
+ }),
+) {
+ const truncated = vi.fn(),
+ exit = vi.fn(),
+ error = vi.fn();
+ return {
+ stream: new TerminalOutputReader(
+ runtime,
+ terminal,
+ write,
+ truncated,
+ exit,
+ error,
+ ),
+ write,
+ truncated,
+ exit,
+ error,
+ };
+}
+
+describe("terminal output recovery", () => {
+ it("uses UTF-8 byte cursors and ignores overlapping notifications", async () => {
+ const first = deferred();
+ const runtime = new Runtime(async ({ offset }) =>
+ offset === 0
+ ? first.promise
+ : page(7, "!", { exited: true, exitCode: 0 }),
+ );
+ const test = reader(runtime);
+ const done = test.stream.recover();
+ test.stream.receive(8);
+ first.resolve(page(0, "汉🙂"));
+ await done;
+ test.stream.receive(7);
+ test.stream.receive(8);
+ expect(test.write.mock.calls.map(([data]) => data).join("")).toBe("汉🙂!");
+ expect(runtime.calls.map((call) => call.offset)).toEqual([0, 7]);
+ expect(test.exit).toHaveBeenCalledExactlyOnceWith(0);
+ });
+
+ it("finishes captured windows before catching up, then reports exit after the final byte", async () => {
+ const runtime = new Runtime(async ({ offset }) =>
+ offset === 0
+ ? page(0, "a", { headOffset: 4, hasMore: true })
+ : offset === 1
+ ? page(1, "bcd", { headOffset: 8, exited: true, exitCode: 0 })
+ : page(4, "efgh", { exited: true, exitCode: 0 }),
+ );
+ const test = reader(runtime);
+ await test.stream.recover();
+ expect(runtime.calls.map((call) => call.through)).toEqual([
+ undefined,
+ 4,
+ undefined,
+ ]);
+ expect(test.write.mock.calls.map(([data]) => data).join("")).toBe(
+ "abcdefgh",
+ );
+ expect(test.exit).toHaveBeenCalledOnce();
+ expect(test.exit.mock.invocationCallOrder[0]).toBeGreaterThan(
+ test.write.mock.invocationCallOrder.at(-1)!,
+ );
+ });
+
+ it("marks an evicted window once and resumes from the retained byte boundary", async () => {
+ const runtime = new Runtime(async ({ offset }) =>
+ offset === 0
+ ? page(100, "汉", {
+ truncated: true,
+ availableFrom: 100,
+ exited: true,
+ exitCode: 0,
+ })
+ : page(103, "", { availableFrom: 100, exited: true, exitCode: 0 }),
+ );
+ const test = reader(runtime);
+ await test.stream.recover();
+ expect(test.truncated).toHaveBeenCalledOnce();
+ expect(test.write).toHaveBeenCalledExactlyOnceWith("汉");
+ expect(test.exit).toHaveBeenCalledOnce();
+ expect(runtime.calls.map((call) => call.offset)).toEqual([0, 103]);
+ });
+
+ it("coalesces warnings and checks for a lost exit notification", async () => {
+ const pending = deferred();
+ const runtime = new Runtime(async ({ offset }) =>
+ offset === 0
+ ? pending.promise
+ : page(1, "", { exited: true, exitCode: 0 }),
+ );
+ const test = reader(runtime);
+ const done = test.stream.recover();
+ for (let index = 0; index < 50; index++)
+ expect(test.stream.recover()).toBe(done);
+ expect(runtime.calls).toHaveLength(1);
+ pending.resolve(page(0, "x"));
+ await done;
+ expect(runtime.calls).toHaveLength(2);
+ expect(test.exit).toHaveBeenCalledOnce();
+ });
+
+ it("waits for the renderer and cancels a blocked write on detach", async () => {
+ const render = deferred();
+ const runtime = new Runtime(async () =>
+ page(0, "a", { hasMore: true, headOffset: 2 }),
+ );
+ const write = vi.fn(async (text: string) => {
+ void text;
+ return render.promise;
+ });
+ const test = reader(runtime, write);
+ const done = test.stream.recover();
+ await vi.waitFor(() => expect(write).toHaveBeenCalledOnce());
+ expect(runtime.calls).toHaveLength(1);
+ test.stream.stop();
+ await done;
+ expect(runtime.calls).toHaveLength(1);
+ render.resolve();
+ });
+
+ it("retries only the unread output after a read failure", async () => {
+ let calls = 0;
+ const runtime = new Runtime(async () => {
+ calls++;
+ if (calls === 1) return page(0, "a", { headOffset: 2, hasMore: true });
+ if (calls === 2) throw new Error("disconnected");
+ return page(1, "b");
+ });
+ const test = reader(runtime);
+ await expect(test.stream.recover()).rejects.toThrow("disconnected");
+ await test.stream.recover();
+ expect(test.write.mock.calls.map(([data]) => data)).toEqual(["a", "b"]);
+ expect(runtime.calls.map((call) => call.offset)).toEqual([0, 1, 1]);
+ });
+
+ it("reduces pages to fit a small transport frame", async () => {
+ const runtime = new Runtime(async ({ limit = 0 }) => {
+ if (limit > 128) throw { code: "RESPONSE_TOO_LARGE" };
+ return page(0, "🙂");
+ });
+ const test = reader(runtime);
+ await test.stream.recover();
+ expect(test.write).toHaveBeenCalledExactlyOnceWith("🙂");
+ expect(runtime.calls.at(-1)?.limit).toBe(128);
+ });
+
+ it.each([
+ page(0, "", { headOffset: 1 }),
+ page(0, "a", { nextOffset: 3, headOffset: 3 }),
+ page(0, "", { hasMore: true }),
+ ])("rejects broken cursors instead of spinning", async (broken) => {
+ const runtime = new Runtime(async () => broken);
+ await expect(reader(runtime).stream.recover()).rejects.toThrow(
+ "Terminal output",
+ );
+ expect(runtime.calls).toHaveLength(1);
+ });
+});
diff --git a/desktop/src/features/workbench/terminalOutputReader.ts b/desktop/src/features/workbench/terminalOutputReader.ts
new file mode 100644
index 000000000..a7f60912d
--- /dev/null
+++ b/desktop/src/features/workbench/terminalOutputReader.ts
@@ -0,0 +1,137 @@
+import type { TerminalInfo, MethodResults } from "../../generated/app-server";
+import type { RpcTransport } from "../../rpc/contracts";
+
+/** Output-only recovery. Input writes are deliberately outside this reader. */
+export class TerminalOutputReader {
+ private offset = 0;
+ private observedHead = 0;
+ private revision = 0;
+ private active = true;
+ private ended = false;
+ private pending: Promise | null = null;
+ private cancelWrite: (() => void) | null = null;
+
+ constructor(
+ private readonly runtime: RpcTransport,
+ readonly terminal: TerminalInfo,
+ private readonly write: (data: string) => Promise,
+ private readonly truncate: () => void,
+ private readonly exit: (code: number | null) => void,
+ private readonly onError: (error: unknown) => void,
+ ) {}
+
+ stop(): void {
+ this.active = false;
+ this.cancelWrite?.();
+ }
+
+ receive(nextOffset: number): void {
+ if (!this.active || nextOffset <= this.offset) return;
+ this.observedHead = Math.max(this.observedHead, nextOffset);
+ if (!this.pending) void this.recover().catch(this.onError);
+ }
+
+ recover(): Promise {
+ if (!this.active) return Promise.resolve();
+ this.revision++;
+ if (!this.pending) this.pending = this.read();
+ return this.pending;
+ }
+
+ private async read(): Promise {
+ let limit = 16 * 1024;
+ try {
+ while (this.active) {
+ const revision = this.revision;
+ const before = this.offset;
+ let through: number | undefined;
+ let page: MethodResults["terminal/read"];
+ for (;;) {
+ try {
+ page = await this.runtime.request("terminal/read", {
+ threadId: this.terminal.threadId,
+ terminalId: this.terminal.terminalId,
+ offset: this.offset,
+ limit,
+ ...(through === undefined ? {} : { through }),
+ });
+ } catch (error) {
+ if (!this.active) return;
+ if (
+ (error as { code?: string } | null)?.code ===
+ "RESPONSE_TOO_LARGE" &&
+ limit > 4
+ ) {
+ limit = Math.max(4, Math.floor(limit / 2));
+ continue;
+ }
+ throw error;
+ }
+ if (!this.active) return;
+ const bytes = new TextEncoder().encode(page.data).length;
+ if (
+ page.terminalId !== this.terminal.terminalId ||
+ page.threadId !== this.terminal.threadId ||
+ page.offset < this.offset ||
+ (!page.truncated && page.offset !== this.offset) ||
+ page.nextOffset !== page.offset + bytes ||
+ page.headOffset < page.nextOffset ||
+ (page.hasMore && bytes === 0)
+ ) {
+ throw new Error(
+ "Terminal output cursor is inconsistent; reopen the terminal view",
+ );
+ }
+ if (page.truncated) this.truncate();
+ if (page.data) await this.render(page.data);
+ if (!this.active) return;
+ this.offset = page.nextOffset;
+ this.observedHead = Math.max(this.observedHead, page.headOffset);
+ through ??= page.headOffset;
+ // Eviction can pass a previously captured cutoff; start a new window.
+ if (page.truncated) through = undefined;
+ if (page.truncated && !page.hasMore) {
+ this.revision++;
+ break;
+ }
+ if (!page.hasMore) break;
+ }
+ if (this.observedHead > this.offset && this.offset === before) {
+ throw new Error("Terminal output recovery made no progress");
+ }
+ if (
+ page.exited &&
+ this.offset === page.headOffset &&
+ !page.truncated &&
+ !this.ended
+ ) {
+ this.ended = true;
+ this.exit(page.exitCode);
+ }
+ if (revision === this.revision && this.observedHead <= this.offset)
+ return;
+ }
+ } finally {
+ this.pending = null;
+ }
+ }
+
+ private render(data: string): Promise {
+ // One cancellable renderer write, without accumulating handlers on a
+ // never-settled cancellation promise during a long-lived terminal session.
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const finish = (error?: unknown) => {
+ if (settled) return;
+ settled = true;
+ this.cancelWrite = null;
+ if (error === undefined) resolve();
+ else reject(error);
+ };
+ this.cancelWrite = () => finish();
+ Promise.resolve()
+ .then(() => (this.active ? this.write(data) : undefined))
+ .then(() => finish(), finish);
+ });
+ }
+}
diff --git a/desktop/src/features/workbench/useCodeWorkbench.test.tsx b/desktop/src/features/workbench/useCodeWorkbench.test.tsx
index 69043b6e7..c1c113e49 100644
--- a/desktop/src/features/workbench/useCodeWorkbench.test.tsx
+++ b/desktop/src/features/workbench/useCodeWorkbench.test.tsx
@@ -12,7 +12,7 @@ import type {
} from "../../generated/app-server";
import type {
AnyRpcNotification,
- DesktopRuntime,
+ ClientRuntime,
RpcMethod,
SidecarStatus,
} from "../../rpc/contracts";
@@ -100,7 +100,7 @@ const testItem: Item = {
updatedAt: "2026-07-16T00:00:00Z",
};
-class WorkbenchRuntime implements DesktopRuntime {
+class WorkbenchRuntime implements ClientRuntime {
readonly firstFiles = deferred();
async request(
@@ -202,7 +202,7 @@ it("does not let a slow previous Thread overwrite the active workbench", async (
expect(result.current.entries[0]?.path).toBe("b.txt");
});
-class MutationRuntime implements DesktopRuntime {
+class MutationRuntime implements ClientRuntime {
readonly calls: Array<{ method: RpcMethod; params: unknown }> = [];
readonly writeResult = deferred();
fileListCount = 0;
diff --git a/desktop/src/features/workbench/useCodeWorkbench.ts b/desktop/src/features/workbench/useCodeWorkbench.ts
index 7f9d880fc..73fed0f63 100644
--- a/desktop/src/features/workbench/useCodeWorkbench.ts
+++ b/desktop/src/features/workbench/useCodeWorkbench.ts
@@ -9,7 +9,7 @@ import type {
TestCommand,
Thread,
} from "../../generated/app-server";
-import type { BridgeError, DesktopRuntime } from "../../rpc/contracts";
+import type { BridgeError, ClientRuntime } from "../../rpc/contracts";
function errorMessage(error: unknown): string {
if (typeof error === "object" && error !== null && "message" in error) {
@@ -60,7 +60,7 @@ const initialState: CodeWorkbenchState = {
};
export function useCodeWorkbench(
- runtime: DesktopRuntime,
+ runtime: ClientRuntime,
thread: Thread | null,
): CodeWorkbenchController {
const [state, setState] = useState(initialState);
diff --git a/desktop/src/features/workflows/WorkflowComposer.tsx b/desktop/src/features/workflows/WorkflowComposer.tsx
index e4d4a9e67..b2913b8eb 100644
--- a/desktop/src/features/workflows/WorkflowComposer.tsx
+++ b/desktop/src/features/workflows/WorkflowComposer.tsx
@@ -53,15 +53,22 @@ export function WorkflowComposer({
const [enableIndexing, setEnableIndexing] = useState(false);
const [planReview, setPlanReview] = useState(true);
const [feedback, setFeedback] = useState("");
- const active = workflow && ["queued", "running", "waiting"].includes(workflow.status);
+ const [fileError, setFileError] = useState(null);
+ const active =
+ workflow && ["queued", "running", "waiting"].includes(workflow.status);
const interaction = useMemo(() => workflowInteraction(workflow), [workflow]);
const progress = workflow?.progressTotal
? Math.round((workflow.progressCurrent / workflow.progressTotal) * 100)
: 0;
const chooseFile = async () => {
- const path = await onPickFile();
- if (path) setSource(path);
+ setFileError(null);
+ try {
+ const path = await onPickFile();
+ if (path) setSource(path);
+ } catch (error) {
+ setFileError(error instanceof Error ? error.message : String(error));
+ }
};
const start = async () => {
@@ -78,6 +85,7 @@ export function WorkflowComposer({
return (
@@ -99,7 +109,9 @@ export function WorkflowComposer({
{interaction.title}
{interaction.description}
- {interaction.planPreview ? {interaction.planPreview} : null}
+ {interaction.planPreview ? (
+ {interaction.planPreview}
+ ) : null}