diff --git a/.github/scripts/generate_star_history.py b/.github/scripts/generate_star_history.py
new file mode 100644
index 0000000000..2d6ac42643
--- /dev/null
+++ b/.github/scripts/generate_star_history.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Generate a self-hosted cumulative GitHub stars chart for the README."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import math
+import os
+import urllib.request
+from pathlib import Path
+
+
+def fetch_stars(repository: str, token: str) -> list[dt.date]:
+ url = f"https://api.github.com/repos/{repository}/stargazers?per_page=100"
+ dates: list[dt.date] = []
+ while url:
+ request = urllib.request.Request(
+ url,
+ headers={
+ "Accept": "application/vnd.github.star+json",
+ "Authorization": f"Bearer {token}",
+ "User-Agent": "jcode-star-history",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ )
+ with urllib.request.urlopen(request) as response:
+ for star in json.load(response):
+ dates.append(dt.datetime.fromisoformat(star["starred_at"].replace("Z", "+00:00")).date())
+ links = response.headers.get("Link", "")
+ url = ""
+ for link in links.split(","):
+ if 'rel="next"' in link:
+ url = link[link.index("<") + 1 : link.index(">")]
+ break
+ return dates
+
+
+def week_start(day: dt.date) -> dt.date:
+ """Return the Monday containing ``day``."""
+ return day - dt.timedelta(days=day.weekday())
+
+
+def render_svg(repository: str, dates: list[dt.date], today: dt.date | None = None) -> str:
+ if not dates:
+ raise RuntimeError("GitHub returned no stargazers")
+ today = today or dt.date.today()
+ dates.sort()
+ current_week = week_start(today)
+ weeks = [current_week - dt.timedelta(weeks=week) for week in reversed(range(26))]
+ values = [sum(day <= min(week + dt.timedelta(days=6), today) for day in dates) for week in weeks]
+ weekly = [
+ sum(week <= day <= min(week + dt.timedelta(days=6), today) for day in dates)
+ for week in weeks
+ ]
+
+ width, height = 800, 420
+ left, right, top, bottom = 68, 24, 78, 64
+ plot_w, plot_h = width - left - right, height - top - bottom
+ latest = values[-1]
+ magnitude = 10 ** max(0, len(str(latest)) - 2)
+ step = max(magnitude, math.ceil(latest / 4 / magnitude) * magnitude)
+ grid_max = max(step * 4, 4)
+
+ def x(index: int) -> float:
+ return left + index * plot_w / (len(weeks) - 1)
+
+ def y(value: int) -> float:
+ return top + (1 - value / grid_max) * plot_h
+
+ y_ticks = []
+ for index in range(5):
+ value = step * index
+ yy = y(value)
+ label = f"{value / 1000:g}k" if value >= 1000 else str(value)
+ y_ticks.append(f'{label}')
+
+ points = " ".join(f"{x(index):.1f},{y(value):.1f}" for index, value in enumerate(values))
+ area = f"{left},{top + plot_h:.1f} {points} {width-right},{top + plot_h:.1f}"
+ x_ticks = []
+ dots = []
+ for index, (week, value, gain) in enumerate(zip(weeks, values, weekly)):
+ if index % 4 == 0 or index == len(weeks) - 1:
+ x_ticks.append(f'{week:%b %-d}')
+ current = " current" if week == current_week else ""
+ radius = 5 if current else 3
+ dots.append(
+ f''
+ f'{week:%b %-d}: {value:,} total stars (+{gain:,} that week)'
+ )
+
+ return f'''
+'''
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo", default="1jehuang/jcode")
+ parser.add_argument("--output", type=Path, default=Path("docs/images/star-history.svg"))
+ args = parser.parse_args()
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+ if not token:
+ raise SystemExit("GITHUB_TOKEN or GH_TOKEN is required")
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(render_svg(args.repo, fetch_stars(args.repo, token)))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4bd8c40fdd..a285b327b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,8 @@
name: CI
+env:
+ JCODE_CI: "1"
+
on:
push:
branches: [main, master]
@@ -39,6 +42,12 @@ jobs:
key: quality-ubuntu
cache-all-crates: "true"
+ - name: Check module declarations resolve
+ # A `mod x;` with no file makes rustfmt fail with "Error writing files:
+ # failed to resolve mod", which reads like a formatting problem and hides
+ # every gate behind it. Naming the real cause first (221159294).
+ run: python3 scripts/check_module_files.py
+
- name: Check formatting
run: cargo fmt --all -- --check
@@ -48,6 +57,15 @@ jobs:
- name: Run clippy with warnings denied
run: cargo clippy --all-targets --all-features -- -D warnings
+ - name: Enforce Cargo.lock is up to date
+ shell: bash
+ # Adding a dependency without regenerating Cargo.lock breaks every
+ # `--locked` build. Only the Windows jobs pass `--locked`, so such a
+ # commit passes 8 of 9 CI jobs and fails Windows at "Build release
+ # binary", skipping all of its validation steps. This catches it in
+ # seconds, on the job that already owns dependency hygiene.
+ run: cargo metadata --locked --format-version 1 > /dev/null
+
- name: Enforce warning budget
shell: bash
run: scripts/check_warning_budget.sh
@@ -72,6 +90,10 @@ jobs:
shell: bash
run: python3 scripts/check_dependency_boundaries.py
+ - name: Enforce Rust and TypeScript SDK surface parity
+ shell: bash
+ run: cargo test -p jcode-sdk parity -- --nocapture
+
- name: Enforce wildcard re-export ratchet
shell: bash
run: python3 scripts/check_wildcard_reexport_budget.py
@@ -82,10 +104,29 @@ jobs:
cargo install cargo-machete --locked
cargo machete
+ release-automation:
+ name: Release Automation
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Test Discord release announcements
+ run: python3 -m unittest -v scripts/test_post_discord_release.py
+
+ - name: Compile release automation scripts
+ run: python3 -m py_compile scripts/post_discord_release.py scripts/test_post_discord_release.py
+
build:
name: Build & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
- timeout-minutes: 35
+ # 35 minutes covers a normal push (~12 min with a warm Swatinem cache) but
+ # not a *rerun*, where the cache is cold: three consecutive reruns of a
+ # known-good commit were cancelled at the cap, each getting further through
+ # the same green steps (#693). A rerun is exactly what you reach for after a
+ # concurrency cancellation, so the old cap made healthy commits look red.
+ # 75 still bounds a genuinely hung job well under the windows job's 150.
+ timeout-minutes: 75
# Some dependencies (e.g. convert_case 0.10.0 via derive_more/crossterm, and
# proc-macro2/quote) accidentally ship a `rust-toolchain.toml` *inside their
# published crate*. When cargo builds such a crate its CWD is that crate dir,
@@ -169,6 +210,133 @@ jobs:
python3 .github/scripts/run_with_timeout.py 900 \
"$(rustup which cargo)" test --target ${{ matrix.target }} --lib --bins --no-run
+ - name: Run deterministic retention-readiness cohort
+ shell: bash
+ run: |
+ # A cold jcode-app-core test harness compiles heavy optional provider
+ # dependencies and can exceed three minutes on hosted runners. The
+ # cohort itself runs in under a second once the lib test is built.
+ python3 .github/scripts/run_with_timeout.py 600 \
+ "$(rustup which cargo)" test --target ${{ matrix.target }} \
+ -p jcode-app-core --lib retention_readiness -- --nocapture
+
+ - name: Run secret-input pty cohort (all platforms with a pty)
+ if: runner.os != 'Windows'
+ shell: bash
+ # `jcode-base --lib` is never executed on Linux CI: the only jcode-base
+ # test invocation anywhere in this workflow is a Windows-only
+ # `power_inhibit::tests::windows_` filter. So the masking fix for #660 had
+ # a test that was compiled and never run, which is the same
+ # looks-green-but-never-ran shape as the warning budget and the stdin
+ # detector (#651). These tests fork a pty, so they cannot run on the
+ # Windows runner; everywhere else they take about a second.
+ #
+ # 600s, not 300s: the budget is dominated by cold-compiling the
+ # jcode-base lib test harness, which exceeded 300s on a hosted macOS
+ # runner (run 30591536707) while the cohort itself runs in a second.
+ run: |
+ python3 .github/scripts/run_with_timeout.py 600 \
+ "$(rustup which cargo)" test --target ${{ matrix.target }} \
+ -p jcode-base --lib secret_input -- --nocapture
+
+ - name: Run embedding numeric-stability cohort (Linux only)
+ if: runner.os == 'Linux'
+ shell: bash
+ # `minilm_embedding_is_numerically_stable_across_inference_engines` pins
+ # the model's actual output, which is the only thing that can catch an
+ # inference-engine upgrade silently changing embeddings: persisted
+ # memories are keyed by model_id, which does not change across a tract
+ # bump, so the stale-embedding filter cannot notice (#657).
+ #
+ # That test skips itself when the model is absent, and jcode-embedding
+ # tests are not otherwise run here at all, so without this step it would
+ # never execute. Fetch the model first (~87MB) so the cohort is real
+ # rather than a silent skip.
+ run: |
+ set -euo pipefail
+ model_dir="$HOME/.jcode/models/all-MiniLM-L6-v2"
+ mkdir -p "$model_dir"
+ base="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main"
+ curl -sSfL --retry 3 -o "$model_dir/model.onnx" "$base/onnx/model.onnx"
+ curl -sSfL --retry 3 -o "$model_dir/tokenizer.json" "$base/tokenizer.json"
+ # Fail loudly if the fetch produced something unusable, so this cannot
+ # degrade back into a silent skip.
+ test -s "$model_dir/model.onnx"
+ test -s "$model_dir/tokenizer.json"
+ python3 .github/scripts/run_with_timeout.py 600 \
+ "$(rustup which cargo)" test --target ${{ matrix.target }} \
+ -p jcode-embedding --lib -- --nocapture \
+ | tee /tmp/embedding-cohort.log
+ # The test is present and the harness ran it (rather than the binary
+ # failing to build).
+ grep -q "minilm_embedding_is_numerically_stable_across_inference_engines ... ok" \
+ /tmp/embedding-cohort.log
+ # This is the check that matters. A skipped test still reports "ok",
+ # so the reported result alone cannot distinguish "verified" from
+ # "silently did nothing"; only the skip message can.
+ if grep -q "skip: MiniLM model not present" /tmp/embedding-cohort.log; then
+ echo "::error::embedding cohort skipped despite fetching the model"
+ exit 1
+ fi
+
+ - name: Run stdin-forwarding cohort (all platforms)
+ shell: bash
+ # These tests exercise the per-platform stdin detector, whose
+ # implementations are entirely separate (`/proc/PID/syscall` on Linux,
+ # `proc_pidinfo` + thread state on macOS). They were never gated: the
+ # only `jcode-app-core --lib` run above is filtered to
+ # `retention_readiness`, and the broader suite is Linux-only, so the
+ # macOS detector had no coverage at all. `TH_STATE_WAITING` was defined
+ # as 2 (`TH_STATE_STOPPED`) rather than 3, which silently disabled
+ # macOS stdin forwarding entirely until #651.
+ #
+ # Deliberately not Linux-gated: running this on both Unix platforms is
+ # the whole point. Windows is a separate job and is not covered here,
+ # because these tests drive `head -n1`, which stock Windows does not
+ # provide; the Windows detector still has no test coverage.
+ run: |
+ python3 .github/scripts/run_with_timeout.py 600 \
+ "$(rustup which cargo)" test --target ${{ matrix.target }} \
+ -p jcode-app-core --lib tool::bash::tests::test_stdin_forwarding -- --nocapture
+
+ - name: Run TUI library tests (Linux only)
+ if: runner.os == 'Linux'
+ shell: bash
+ # These were previously only compiled (`--no-run` above), so test-only
+ # breakage reached master with CI green: three `ServerEvent::MessageEnd`
+ # call sites stayed unit variants after the enum gained a field, and the
+ # whole target failed to compile unnoticed (see #592).
+ #
+ # Run serially. Many of these tests share process-global state (model
+ # catalog, ambient cache, render state) and fail on ordering under
+ # parallelism, while passing reliably one-at-a-time: 1977 pass serially
+ # versus 2-4 varying failures in parallel. Scoping that state is tracked
+ # in #592; until then serial execution is the honest signal, and costs
+ # ~30s once the target is already built above.
+ #
+ # Two render tests depend on terminal color support and are still
+ # skipped. The copy-badge cohort no longer is: every one of those tests
+ # now installs the in-process `CapturedClipboard` sink and asserts the
+ # copied text through it, so they never touch the OS clipboard and pass
+ # on a headless runner (verified with DISPLAY/WAYLAND_DISPLAY unset).
+ # They were the tests most worth running, since they cover the copy path
+ # end to end. Tracked in #592.
+ #
+ # COLORTERM: the runner's headless shell advertises no color support,
+ # so capability detection falls back to 256-color and every RGB cell
+ # quantizes to `Indexed`. The palette-topology measurement identifies
+ # roles by their rendered RGB, and quantization error pushes some roles
+ # outside the matcher's family radius ("got 4 edges", run 30617961697).
+ # Declaring truecolor tests the code path users overwhelmingly run.
+ env:
+ COLORTERM: truecolor
+ run: |
+ python3 .github/scripts/run_with_timeout.py 600 \
+ "$(rustup which cargo)" test --target ${{ matrix.target }} \
+ -p jcode-tui --lib -- --test-threads=1 \
+ --skip test_prompt_entry_shimmer_color_moves_across_positions \
+ --skip right_fact_stack_uses_neutral_gray_except_for_context_usage
+
- name: Compile integration test binaries
shell: bash
# Build the integration-test binaries up front (not counted against the
@@ -201,8 +369,10 @@ jobs:
run: |
./scripts/check_powershell_syntax.ps1
- - name: Enforce warning budget (Linux)
- if: runner.os == 'Linux'
+ # Linux and macOS. Windows still has its own cfg-gated surface and is
+ # not yet warning-clean, so it stays out of the gate (#1177).
+ - name: Enforce warning budget (Linux, macOS)
+ if: runner.os == 'Linux' || runner.os == 'macOS'
shell: bash
run: |
scripts/check_warning_budget.sh
@@ -283,8 +453,14 @@ jobs:
'build_shell_command_uses_cmd_and_executes_command',
'pipe_name_is_stable_and_normalizes_case_and_separators',
'pipe_name_falls_back_when_stem_is_empty',
+ 'busy_pipe_is_reported_as_a_live_socket_path',
'stream_pair_round_trips_bytes',
- 'split_stream_supports_concurrent_read_and_write'
+ 'split_stream_supports_concurrent_read_and_write',
+ 'test_cancel_command_idle_reports_nothing_to_cancel',
+ 'test_menu_number_rejected_as_api_key',
+ 'test_command_palette_suppressed_while_api_key_prompt_pending',
+ 'test_ctrl_c_with_active_copy_selection_copies_instead_of_quitting',
+ 'test_ctrl_c_in_copy_mode_without_selection_still_falls_through'
)
foreach ($testName in $tests) {
@@ -387,9 +563,63 @@ jobs:
with:
components: rustfmt
+ - name: Check module declarations resolve
+ # A `mod x;` with no file makes rustfmt fail with "Error writing files:
+ # failed to resolve mod", which reads like a formatting problem and hides
+ # every gate behind it. Naming the real cause first (221159294).
+ run: python3 scripts/check_module_files.py
+
- name: Check formatting
run: cargo fmt --all -- --check
+ typescript-sdk:
+ name: TypeScript SDK
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: sdk/typescript/package-lock.json
+
+ # The SDK mirrors crates/jcode-harness-api by hand. Without this job the
+ # only guard that runs on a change is the Rust-side variant check, and a
+ # typo in the TypeScript itself (or a broken client) reaches consumers.
+ - name: Install
+ run: npm ci --no-audit --no-fund
+ working-directory: sdk/typescript
+
+ - name: Typecheck, build, and test
+ run: npm run check
+ working-directory: sdk/typescript
+
+ # `npm run check` proves the source compiles; it says nothing about what
+ # a consumer actually receives. The published tarball is a separate
+ # artifact (`files`, `exports`, `prepack`), and getting it wrong ships a
+ # package that installs but cannot be imported. Install it as a real
+ # dependency and import it the way a consumer would.
+ - name: Published tarball installs and imports
+ run: bash scripts/test_sdk_package.sh
+
+ setup-friction:
+ name: Setup Friction Eval (Linux installer)
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install probe shells
+ run: sudo apt-get update && sudo apt-get install -y fish zsh
+
+ - name: Installer conversion telemetry tests
+ run: bash scripts/test_install_conversion.sh
+
+ - name: Setup friction scorecard
+ run: bash scripts/setup_friction_eval.sh
+
powershell-syntax:
name: PowerShell Syntax
runs-on: windows-latest
diff --git a/.github/workflows/discord-release.yml b/.github/workflows/discord-release.yml
index cd6ea16041..e46141e801 100644
--- a/.github/workflows/discord-release.yml
+++ b/.github/workflows/discord-release.yml
@@ -1,50 +1,39 @@
name: Announce release on Discord
+env:
+ JCODE_CI: "1"
+
on:
+ # Covers releases published outside the tag-driven release workflow. Events
+ # created by GITHUB_TOKEN are suppressed, so release.yml also dispatches this
+ # workflow explicitly after it publishes a release.
release:
types: [published]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Existing public release tag to announce
+ required: true
+ type: string
+
+concurrency:
+ group: discord-release-${{ inputs.tag || github.event.release.tag_name }}
+ cancel-in-progress: false
permissions:
- contents: read
+ contents: write
jobs:
announce:
runs-on: ubuntu-latest
steps:
- - name: Post release notes to Discord
- env:
- WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
- TAG: ${{ github.event.release.tag_name }}
- NAME: ${{ github.event.release.name }}
- BODY: ${{ github.event.release.body }}
- URL: ${{ github.event.release.html_url }}
- run: |
- python3 - <<'EOF'
- import json, os, re, urllib.request
-
- tag = os.environ["TAG"]
- name = os.environ.get("NAME") or tag
- body = os.environ.get("BODY") or ""
- url = os.environ["URL"]
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.repository.default_branch }}
- # Strip platform-availability boilerplate and collapse blank lines
- body = re.sub(r".*", "", body, flags=re.S)
- body = re.sub(r"\n{3,}", "\n\n", body).strip()
-
- title = f"## {tag}" + (f" \u2014 {name}" if name != tag else "")
- msg = f"{title}\n{body}"
- suffix = f"\n\u2026 (full notes: <{url}>)"
- if len(msg) > 1995:
- msg = msg[:1995 - len(suffix)] + suffix
-
- req = urllib.request.Request(
- os.environ["WEBHOOK_URL"],
- data=json.dumps({"content": msg}).encode(),
- headers={
- "Content-Type": "application/json",
- "User-Agent": "jcode-release-bot/1.0",
- },
- )
- urllib.request.urlopen(req)
- print(f"Posted {tag} to Discord")
- EOF
+ - name: Post release notes to Discord if not already announced
+ env:
+ GH_TOKEN: ${{ github.token }}
+ DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
+ TAG: ${{ inputs.tag || github.event.release.tag_name }}
+ run: python3 scripts/post_discord_release.py --tag "$TAG"
diff --git a/.github/workflows/freebsd-smoke.yml b/.github/workflows/freebsd-smoke.yml
index dab88dbb7c..e3b3ef4ffa 100644
--- a/.github/workflows/freebsd-smoke.yml
+++ b/.github/workflows/freebsd-smoke.yml
@@ -1,5 +1,8 @@
name: FreeBSD Smoke
+env:
+ JCODE_CI: "1"
+
# Builds and smoke-tests jcode inside a real FreeBSD VM.
#
# GitHub does not offer native FreeBSD runners, so we boot a FreeBSD guest
diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml
index 749d7bfd6e..3a15377236 100644
--- a/.github/workflows/ios-testflight.yml
+++ b/.github/workflows/ios-testflight.yml
@@ -1,5 +1,8 @@
name: iOS TestFlight
+env:
+ JCODE_CI: "1"
+
on:
workflow_dispatch:
push:
@@ -84,21 +87,21 @@ jobs:
printf '%s' "$APPSTORE_API_KEY_P8" > ~/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8
chmod 600 ~/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8
- - name: Archive (cloud-managed signing)
+ - name: Archive (unsigned; signing happens at export)
working-directory: ios
run: |
set -o pipefail
+ # The team has no registered devices, so a development profile can
+ # never be created at archive time. Archive unsigned; the export
+ # step signs with cloud-managed Apple Distribution via the ASC key.
xcodebuild archive \
-project JCodeMobile.xcodeproj \
-scheme "$SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath build/JCodeMobile.xcarchive \
- -allowProvisioningUpdates \
- -authenticationKeyPath "$HOME/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8" \
- -authenticationKeyID "${{ secrets.APPSTORE_API_KEY_ID }}" \
- -authenticationKeyIssuerID "${{ secrets.APPSTORE_ISSUER_ID }}" \
- CODE_SIGN_STYLE=Automatic \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGN_IDENTITY="" \
DEVELOPMENT_TEAM="$TEAM_ID" \
CURRENT_PROJECT_VERSION="${{ github.run_number }}" \
| tail -100
@@ -117,6 +120,8 @@ jobs:
upload
teamID
TAS6ARKDN7
+ signingStyle
+ automatic
uploadSymbols
manageAppVersionAndBuildNumber
diff --git a/.github/workflows/publish-typescript-sdk.yml b/.github/workflows/publish-typescript-sdk.yml
new file mode 100644
index 0000000000..60e363ee3e
--- /dev/null
+++ b/.github/workflows/publish-typescript-sdk.yml
@@ -0,0 +1,79 @@
+name: Publish TypeScript SDK
+
+env:
+ JCODE_CI: "1"
+
+on:
+ workflow_dispatch:
+ inputs:
+ jcode_release_tag:
+ description: Released jcode tag whose binaries should be bundled
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ name: Publish @1jehuang/jcode-sdk
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ defaults:
+ run:
+ working-directory: sdk/typescript
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ registry-url: https://registry.npmjs.org
+ cache: npm
+ cache-dependency-path: sdk/typescript/package-lock.json
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Enforce Rust and TypeScript SDK surface parity
+ working-directory: ${{ github.workspace }}
+ run: cargo test -p jcode-sdk parity -- --nocapture
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Validate SDK
+ run: npm run check
+
+ - name: Download released jcode runtimes
+ working-directory: ${{ github.workspace }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ mkdir -p sdk-runtime-assets
+ gh release download "${{ inputs.jcode_release_tag }}" --dir sdk-runtime-assets \
+ --pattern 'jcode-linux-x86_64.tar.gz' \
+ --pattern 'jcode-linux-aarch64.tar.gz' \
+ --pattern 'jcode-macos-x86_64.tar.gz' \
+ --pattern 'jcode-macos-aarch64.tar.gz' \
+ --pattern 'jcode-windows-x86_64.tar.gz' \
+ --pattern 'jcode-windows-aarch64.tar.gz'
+
+ - name: Prepare platform packages
+ working-directory: ${{ github.workspace }}
+ run: bash scripts/prepare_sdk_runtime_packages.sh sdk-runtime-assets
+
+ # These must exist before the main package is published. npm selects only
+ # the package matching the consumer's os/cpu from optionalDependencies.
+ - name: Publish platform runtime packages
+ working-directory: ${{ github.workspace }}
+ run: |
+ for package in sdk/npm/*; do
+ npm publish "$package" --access public --provenance
+ done
+
+ - name: Publish with npm provenance
+ run: npm publish --access public --provenance
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 574207b690..fb4de7f383 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,5 +1,11 @@
name: Release
+env:
+ JCODE_CI: "1"
+ CARGO_TERM_COLOR: always
+ SCCACHE_GHA_ENABLED: "true"
+ CARGO_INCREMENTAL: "0"
+
on:
push:
tags:
@@ -12,11 +18,6 @@ concurrency:
permissions:
contents: write
-env:
- CARGO_TERM_COLOR: always
- SCCACHE_GHA_ENABLED: "true"
- CARGO_INCREMENTAL: "0"
-
jobs:
create-release:
name: Create release
@@ -99,6 +100,19 @@ jobs:
with:
targets: ${{ matrix.target }}
+ # Termux (Android) kernels implement ELF TLS Variant 1 while glibc
+ # expects Variant 2, so native __thread variables are zero-initialized at
+ # runtime and Tokio's runtime detection false-positives and panics.
+ # Building the aarch64 glibc binary with emulated TLS
+ # (pthread_getspecific) sidesteps kernel TLS entirely. That needs
+ # nightly (-Z tls-model, -Z build-std) with rust-src.
+ - name: Install nightly for emulated TLS (aarch64 only)
+ if: matrix.target == 'aarch64-unknown-linux-gnu'
+ uses: dtolnay/rust-toolchain@nightly
+ with:
+ targets: aarch64-unknown-linux-gnu
+ components: rust-src
+
- name: Setup sccache
uses: mozilla-actions/sccache-action@v0.0.7
continue-on-error: true
@@ -125,14 +139,24 @@ jobs:
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
+ [target.aarch64-unknown-linux-gnu]
+ linker = "clang"
+ rustflags = ["-C", "link-arg=-fuse-ld=mold", "-Z", "tls-model=emulated"]
EOF
fi
if command -v sccache &>/dev/null && sccache --start-server 2>/dev/null; then
export RUSTC_WRAPPER=sccache
fi
- cargo build --release --target ${{ matrix.target }}
+ if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
+ # Rebuild std with emulated TLS too; see the nightly install step
+ # above for why Termux needs this.
+ cargo +nightly build -Z build-std=std,panic_abort --release --target ${{ matrix.target }}
+ else
+ cargo build --release --target ${{ matrix.target }}
+ fi
env:
JCODE_RELEASE_BUILD: "1"
+ JCODE_CI_BUILD: "1"
JCODE_BUILD_SEMVER: ${{ github.ref_name }}
- name: Build portable Linux x86_64 release binary
@@ -141,6 +165,7 @@ jobs:
run: scripts/build_linux_compat.sh dist
env:
JCODE_RELEASE_BUILD: "1"
+ JCODE_CI_BUILD: "1"
JCODE_BUILD_SEMVER: ${{ github.ref_name }}
JCODE_COMPAT_ARTIFACT: ${{ matrix.artifact }}
@@ -242,6 +267,7 @@ jobs:
& cargo @cargoArgs
env:
JCODE_RELEASE_BUILD: "1"
+ JCODE_CI_BUILD: "1"
JCODE_BUILD_SEMVER: ${{ github.ref_name }}
- name: Verify built Windows binary launches
@@ -407,6 +433,7 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
run: |
gh release upload "${env:GITHUB_REF_NAME}" dist/jcode-windows-x86_64.exe dist/jcode-windows-x86_64.tar.gz dist/jcode-windows-aarch64.exe dist/jcode-windows-aarch64.tar.gz --clobber
@@ -450,6 +477,7 @@ jobs:
# CARGO_BUILD_JOBS keeps memory in check; aws-lc-sys is memory hungry.
export CARGO_BUILD_JOBS=3
export JCODE_RELEASE_BUILD=1
+ export JCODE_CI_BUILD=1
export JCODE_BUILD_SEMVER="${{ github.ref_name }}"
echo "::group::cargo build (jcode binary)"
@@ -486,6 +514,10 @@ jobs:
if: ${{ always() && needs.create-release.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 15
+ permissions:
+ actions: write
+ contents: write
+ issues: write
steps:
- uses: actions/checkout@v4
with:
@@ -497,15 +529,31 @@ jobs:
path: artifacts
pattern: jcode-*
- - name: Validate at least one completed platform asset
+ - name: Validate complete platform asset set
shell: bash
run: |
set -euo pipefail
- mapfile -d '' assets < <(
- find artifacts -type f \( -name '*.tar.gz' -o -name '*.exe' \) -print0 2>/dev/null
+ expected=(
+ artifacts/jcode-linux-x86_64/jcode-linux-x86_64.tar.gz
+ artifacts/jcode-linux-aarch64/jcode-linux-aarch64.tar.gz
+ artifacts/jcode-macos-aarch64/jcode-macos-aarch64.tar.gz
+ artifacts/jcode-macos-x86_64/jcode-macos-x86_64.tar.gz
+ artifacts/jcode-windows-x86_64/jcode-windows-x86_64.exe
+ artifacts/jcode-windows-x86_64/jcode-windows-x86_64.tar.gz
+ artifacts/jcode-windows-aarch64/jcode-windows-aarch64.exe
+ artifacts/jcode-windows-aarch64/jcode-windows-aarch64.tar.gz
+ artifacts/jcode-freebsd-x86_64/jcode-freebsd-x86_64.tar.gz
)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No platform produced a releasable asset; keeping the release as a draft" >&2
+
+ missing=()
+ for asset in "${expected[@]}"; do
+ if [ ! -f "$asset" ]; then
+ missing+=("$asset")
+ fi
+ done
+ if [ "${#missing[@]}" -ne 0 ]; then
+ printf 'Missing required release asset: %s\n' "${missing[@]}" >&2
+ echo "Keeping the release as a draft until every platform succeeds" >&2
exit 1
fi
@@ -659,6 +707,17 @@ jobs:
echo "Release ${GITHUB_REF_NAME} is already public; leaving it public."
fi
+ # Releases created with GITHUB_TOKEN do not trigger `release: published`,
+ # but workflow_dispatch is allowed. Queue the dedicated, per-tag
+ # announcement workflow without letting Discord block package publishing.
+ - name: Queue Discord release announcement
+ id: discord_announcement
+ continue-on-error: true
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ github.token }}
+ run: gh workflow run discord-release.yml --ref "$DEFAULT_BRANCH" -f "tag=${GITHUB_REF_NAME}"
+
- name: Update Homebrew formula
env:
HOMEBREW_DEPLOY_KEY: ${{ secrets.HOMEBREW_DEPLOY_KEY }}
@@ -716,7 +775,7 @@ jobs:
libexec.install Dir["libssl.so*"], Dir["libcrypto.so*"] unless Dir["libssl.so*", "libcrypto.so*"].empty?
(bin/"jcode").write <<~SH
#!/bin/sh
- exec "#{libexec}/jcode-linux-x86_64" "$@"
+ exec "#{libexec}/jcode-linux-x86_64" "\$@"
SH
end
end
@@ -857,3 +916,9 @@ jobs:
--reason completed
gh issue edit "$issue" --remove-label "$label" || true
done
+
+ - name: Report Discord announcement queue failure
+ if: always() && steps.discord_announcement.outcome == 'failure'
+ run: |
+ echo "The release was published, but its Discord announcement could not be queued." >&2
+ exit 1
diff --git a/.github/workflows/require-issue.yml b/.github/workflows/require-issue.yml
index e2a02fd0ce..c3fb0035cd 100644
--- a/.github/workflows/require-issue.yml
+++ b/.github/workflows/require-issue.yml
@@ -1,5 +1,8 @@
name: Require Linked Issue
+env:
+ JCODE_CI: "1"
+
# Ensure every pull request is linked to a REAL GitHub issue in this repo.
# A PR passes when it links an issue via any of:
# * GitHub's "Development" sidebar (closing issue reference), or
diff --git a/.github/workflows/update-star-history.yml b/.github/workflows/update-star-history.yml
new file mode 100644
index 0000000000..7f77e73e9e
--- /dev/null
+++ b/.github/workflows/update-star-history.yml
@@ -0,0 +1,37 @@
+name: Update weekly stars chart
+
+env:
+ JCODE_CI: "1"
+
+on:
+ schedule:
+ - cron: "17 3 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: update-star-history
+ cancel-in-progress: true
+
+jobs:
+ update:
+ if: github.repository == '1jehuang/jcode'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Generate chart using repository-authorized star data
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python3 .github/scripts/generate_star_history.py
+ - name: Commit updated chart
+ run: |
+ if git diff --quiet -- docs/images/star-history.svg; then
+ exit 0
+ fi
+ git config user.name github-actions[bot]
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ git add docs/images/star-history.svg
+ git commit -m "docs: update weekly stars chart"
+ git push
diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml
index 1ae8c60442..c8da370dee 100644
--- a/.github/workflows/windows-smoke.yml
+++ b/.github/workflows/windows-smoke.yml
@@ -1,5 +1,8 @@
name: Windows Smoke
+env:
+ JCODE_CI: "1"
+
on:
workflow_dispatch:
inputs:
@@ -82,6 +85,7 @@ jobs:
'build_shell_command_uses_cmd_and_executes_command',
'pipe_name_is_stable_and_normalizes_case_and_separators',
'pipe_name_falls_back_when_stem_is_empty',
+ 'busy_pipe_is_reported_as_a_live_socket_path',
'stream_pair_round_trips_bytes',
'split_stream_supports_concurrent_read_and_write',
'auto_provider_noninteractive_skips_untrusted_external_auth_instead_of_blocking'
@@ -145,15 +149,8 @@ jobs:
- name: Verify installer using local artifact
shell: pwsh
run: |
- $cargoVersion = Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1
- if (-not $cargoVersion) {
- throw 'Could not determine Cargo.toml version'
- }
-
- $version = 'v' + $cargoVersion.Matches[0].Groups[1].Value
& ./.github/scripts/verify_windows_install.ps1 `
- -ArtifactExePath 'target/x86_64-pc-windows-msvc/release/jcode.exe' `
- -Version $version
+ -ArtifactExePath 'target/x86_64-pc-windows-msvc/release/jcode.exe'
smoke-arm64:
name: Windows Smoke (ARM64)
@@ -204,12 +201,5 @@ jobs:
- name: Verify installer using local artifact
shell: pwsh
run: |
- $cargoVersion = Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1
- if (-not $cargoVersion) {
- throw 'Could not determine Cargo.toml version'
- }
-
- $version = 'v' + $cargoVersion.Matches[0].Groups[1].Value
& ./.github/scripts/verify_windows_install.ps1 `
- -ArtifactExePath 'target/aarch64-pc-windows-msvc/release/jcode.exe' `
- -Version $version
+ -ArtifactExePath 'target/aarch64-pc-windows-msvc/release/jcode.exe'
diff --git a/.gitignore b/.gitignore
index c71a3e3e07..f3692dba2b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@ ios_simulator_screenshot.png
target-ttest/
target-ttest*/
+# Local capture artefacts are not committed baselines.
+captures/
+
# Stray experiment/debug artifacts at the repo root. Real assets live under
# assets/, docs/, ios/, and tests/ and are committed explicitly.
/*.log
diff --git a/.jcode/semantic-todo-migration-spec.md b/.jcode/semantic-todo-migration-spec.md
new file mode 100644
index 0000000000..85673adb68
--- /dev/null
+++ b/.jcode/semantic-todo-migration-spec.md
@@ -0,0 +1,137 @@
+# Semantic Todo Assessment Migration Spec (worker handoff)
+
+Goal: remove all 0-100 quality scores from the todo system. Replace with semantic
+string enums. Add difficulty, autonomy, delivery_state. Legacy numeric sessions
+must still load (numbers map to enums on deserialize). Every todo write must
+always persist (never reject a write; gates only emit continuations). Difficulty
+and autonomy are NEVER gated. Completion gates evaluate confidence (evidence) and
+delivery_state, with difficulty only calibrating how strict the delivery bar is.
+
+## Enums (all: snake_case string serde, Ord by declaration order, in jcode-task-types)
+
+```rust
+pub enum IntentUnderstanding { Uncertain, Partial, Clear, Complete }
+pub enum FeedbackLoopState { Absent, Weak, Usable, Strong, Closed }
+pub enum ConfidenceState { Speculative, Plausible, Validated, Verified }
+pub enum Difficulty { Trivial, Routine, Involved, Complex, Hard, Expert, Research, OpenEnded }
+pub enum Autonomy { RequestedOnly, NecessaryFollowthrough, Proactive, Stewardship }
+pub enum DeliveryState { ChangeMade, Integrated, WorkflowValidated, OutcomeDelivered }
+```
+
+Each enum gets:
+- `#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ...)]`, `#[serde(rename_all = "snake_case")]`
+- `pub fn as_str(&self) -> &'static str`
+- `pub fn parse(&str) -> Option` (trim, ascii-lowercase)
+- `pub fn from_legacy_score(u8) -> Self` per the tables below
+- Deserialization must accept BOTH a string variant AND a legacy integer
+ (use a custom `Deserialize` impl or `#[serde(deserialize_with)]` helper via
+ untagged number-or-string). Serialization is always the string.
+
+## Legacy numeric -> enum mapping
+
+- IntentUnderstanding: 0-59 Uncertain, 60-95 Partial, 96-99 Clear, 100 Complete
+- FeedbackLoopState: 0-19 Absent, 20-49 Weak, 50-79 Usable, 80-95 Strong, 96-100 Closed
+- ConfidenceState: 0-59 Speculative, 60-95 Plausible, 96-99 Validated, 100 Verified
+- DeliveryState (from legacy end_to_end_ownership): 0-49 ChangeMade, 50-79 Integrated,
+ 80-95 WorkflowValidated, 96-100 OutcomeDelivered
+- Difficulty / Autonomy: no legacy field; default None.
+
+## Field changes
+
+TodoItem:
+- `confidence: Option` (was Option)
+- `completion_confidence: Option`
+- `confidence_history: Vec` (legacy Vec entries convert on load)
+- NEW `difficulty: Option` (skip_serializing_if none)
+
+TodoPlan:
+- `understands_user_intent: Option` (keep aliases alignment_score / user_intention_alignment)
+- `understands_user_intent_history: Vec`
+
+TodoGoal:
+- `closed_feedback_loop: Option` (keep hill_climbability alias) + history
+- `end_to_end_ownership` RENAMED to `delivery_state: Option`
+ with `#[serde(alias = "end_to_end_ownership")]`; history field
+ `delivery_state_history` with alias `end_to_end_ownership_history`
+- NEW `difficulty: Option`
+- NEW `autonomy: Option`
+- `feedback_loop: Option` unchanged
+
+GateObservation.score becomes the relevant enum? Keep it simple:
+change `score: Option` to a semantic snapshot; simplest is
+`state: Option` holding as_str, with `#[serde(alias = "score")]`
+tolerating old numeric via number-or-string deserializer. Workers may instead
+keep two optional fields; prefer minimal churn.
+
+## Gate semantics (jcode-base todo.rs, app-core, tui)
+
+Never reject a write. All existing "deferred observation + turn-end digest +
+continuation" plumbing stays, only comparisons change:
+
+- Intent gate: passing when `understands_user_intent >= Clear`.
+ Severe first-write nudge when `== Uncertain` (replaces SEVERE_INTENT_MISUNDERSTANDING).
+- Feedback loop gate: passing when `closed_feedback_loop >= Closed` (i.e. == Closed).
+- Completion confidence gate: completed todo passes when
+ `completion_confidence >= Validated`.
+- Confidence spike: a completed todo is spike-finished when its final history
+ step jumps 2 or more levels (e.g. Speculative -> Validated), or with no
+ history when completion is >= 2 levels above planning confidence.
+- Delivery gate (replaces ownership gate): a completed group passes when its
+ goal's `delivery_state` meets the required bar:
+ - difficulty None | Trivial | Routine -> WorkflowValidated or better
+ - Involved and above -> OutcomeDelivered
+ Difficulty itself is never a gate: absent difficulty just uses the lenient bar.
+- Autonomy: never gated anywhere. Display/telemetry only.
+
+Delete or replace numeric constants (QUALITY_GATE_THRESHOLD, LOW_*,
+SEVERE_INTENT_MISUNDERSTANDING, TODO_CONFIDENCE_SPIKE) with enum-based
+predicates exported from jcode-base::todo, e.g.:
+`intent_understanding_passes`, `feedback_loop_passes`,
+`completion_confidence_passes`, `delivery_state_passes(goal)`,
+`required_delivery_state(difficulty)`.
+TUI code referencing the old constants must use these predicates.
+
+## Tool schema (app-core todo.rs)
+
+- Replace integer 0-100 properties with string enums:
+ - todo item: `confidence`, `completion_confidence` -> enum of
+ speculative|plausible|validated|verified; NEW optional `difficulty` enum.
+ - plan: `understands_user_intent` -> uncertain|partial|clear|complete.
+ - goal: `closed_feedback_loop` -> absent|weak|usable|strong|closed;
+ `end_to_end_ownership` REPLACED by `delivery_state` ->
+ change_made|integrated|workflow_validated|outcome_delivered;
+ NEW optional `difficulty` and `autonomy` enums.
+- normalize_todo_input: keep string coercion; numeric values (int, float,
+ numeric string) for any of these fields must be converted to the mapped
+ enum string so legacy transcripts/providers still parse. Empty string -> null.
+- Histories remain tool-maintained; record_score_observation generalizes over
+ the enums (push when last != new).
+- Telemetry: TelemetryScoreSummary stays u8-based; map each enum to a
+ representative score via `legacy_score()` on each enum:
+ - IntentUnderstanding: 40/80/96/100
+ - FeedbackLoopState: 10/35/65/88/98
+ - ConfidenceState: 40/80/96/100
+ - DeliveryState: 25/65/88/98
+ This keeps jcode-telemetry-core, usage-types, and the worker schema untouched.
+
+## Always-save requirement
+
+Verify no code path returns Err/rejects before `save_todos/save_goals/save_plan`
+based on assessment values. `newly_completed_groups_have_sufficient_ownership`
+(write-time variant) should be removed or kept only for turn-finish; nothing may
+block persisting.
+
+## Testing
+
+- task-types: round-trip each enum; legacy numeric JSON deserializes (e.g.
+ `{"understands_user_intent": 97}` -> Clear); serializes as string.
+- base: update gate tests to enum values; legacy alias fields still load.
+- app-core: schema test (no digits/0-100 in model-visible schema besides none),
+ normalize coercion of numeric legacy input, merge histories, always-save.
+- tui: update todos_view/ui_messages/info_widget tests to render state words.
+- Commands: `cargo test -p jcode-task-types -p jcode-base`,
+ `cargo test -p jcode-app-core todo`, `cargo test -p jcode-tui todo`,
+ `cargo check -p jcode-tui -p jcode-telemetry-core`.
+
+Do NOT touch unrelated dirty files. Commit nothing;
+the coordinator commits.
diff --git a/AGENTS.md b/AGENTS.md
index efd53c54b6..0db8e9e061 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,19 +2,15 @@
## Development Workflow
-- **Commit as you go** - Make small, focused commits after completing each feature or fix
-- If the git state is not clean, or there are other agents working in the codebase in parallel, do your best to still commit your work.
-- **Push when done** - Push all commits to remote when finishing a task or session
-- **Use fast iteration by default** - Prefer `cargo check`, targeted tests, and dev builds while iterating
-- **Rebuild when done** - When you are done making changes, build the source.
-- **Bump version for releases** - Update version in `Cargo.toml` when making releases. When cutting a new release, look at all the changes that happened since the last release and determine what the version bump should be ie patch or minor, etc.
-- **Remote builds available** - Use `scripts/remote_build.sh` to offload heavy cargo work to another machine. If your build is terminated, likely is because there are not enough resources on this machine to build. use remote build in that case. Try checking the resource avaliablity on the machine before you run a build.
-
-## Logs
-- Logs are written to `~/.jcode/logs/` (daily files like `jcode-YYYY-MM-DD.log`).
-
-## Debug Socket
-- Use the debug socket for runtime level debugging
+- **Welcome pull requests from everyone** - Review contributions on their merits,
+ regardless of whether the author is a maintainer, an existing contributor, a
+ first-time contributor, or an agent. Good PRs can be merged directly after review
+ and validation. Do not require a maintainer-authored rewrite merely because of
+ who submitted the change. See `CONTRIBUTING.md` for the contribution policy.
+- **Keep work scoped** - Work on your own branch and preserve unrelated work. When
+ the user asks you to review or integrate a PR or branch, you may inspect, test,
+ and integrate that contribution regardless of author status. Do not pull in
+ unrelated branches or merge a PR without user authorization.
## Install Notes
- `~/.local/bin/jcode` is the launcher symlink used from `PATH`.
@@ -25,3 +21,28 @@
- On Windows, the equivalents are `%LOCALAPPDATA%\\jcode\\bin\\jcode.exe` for the launcher, `%LOCALAPPDATA%\\jcode\\builds\\stable\\jcode.exe` for stable, and `%LOCALAPPDATA%\\jcode\\builds\\versions\\\\jcode.exe` for immutable installs; `scripts/install.ps1` currently installs the stable channel.
- Ensure `~/.local/bin` is **before** `~/.cargo/bin` in `PATH`.
+## Verifying a change at runtime
+
+`cargo build` alone proves nothing about behavior. `jcode run` and interactive
+sessions are served by the long-lived daemon at
+`~/.jcode/builds/shared-server/jcode`, which is a symlink into
+`~/.jcode/builds/versions//`. Until that symlink is repointed and the
+daemon restarted (`jcode self-dev --build`), a freshly built binary is inert and
+every runtime check silently measures the old code.
+
+To test a change without disturbing the shared daemon or the caller's session,
+run your build against its own socket:
+
+```bash
+cargo build --profile selfdev
+./target/selfdev/jcode run --no-update --socket /run/user/1000/jcode-mytest.sock ''
+```
+
+Two things that waste time otherwise:
+
+- `crate::logging::info` writes to a log file, not stderr, so instrumenting a
+ code path with it produces no visible output under `--trace`. Use `eprintln!`
+ for throwaway diagnostics and delete it before committing.
+- Confirm which binary you are actually inspecting. `strings` on
+ `builds/shared-server/jcode` reads a 70-byte symlink, not a program; resolve it
+ with `readlink -f` first.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..cf7bd05a56
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,8 @@
+# Repository Instructions
+
+@AGENTS.md
+
+Follow `AGENTS.md` for the development workflow and `CONTRIBUTING.md` for the
+contribution policy. Pull requests from everyone are welcome for direct review
+and merging, regardless of contributor or maintainer status. Preserve unrelated
+work and obtain user authorization before merging.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 49f1aa329d..d420074442 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,21 +4,23 @@ Thanks for contributing.
## Issues vs pull requests
-If the problem is easy for me to reproduce, please prefer opening a GitHub issue. A clear issue with reproduction steps, expected behavior, actual behavior, logs, screenshots, or traces is usually the fastest path to a fix.
+Both issues and pull requests are welcome. Open an issue to report a bug or discuss an idea, or send a focused PR if you have a fix or improvement ready. For large changes, discuss the approach in an issue first to avoid spending time on work that may not fit the project.
-Pull requests are more useful when the problem depends on an environment I may not have, such as macOS-specific behavior, Windows-specific behavior, unusual shells, terminal emulators, filesystems, GPU/display setups, provider accounts, or other local configuration. In those cases, a PR can be a useful reference because it captures the behavior in the environment where the problem actually occurs.
+A clear issue or PR includes reproduction steps, expected behavior, actual behavior, and relevant logs, screenshots, or traces. Environment-specific fixes are especially helpful when they cover systems the maintainers may not have, such as macOS, Windows, unusual shells, terminal emulators, filesystems, GPU/display setups, or provider accounts.
+
+Every PR must link to an existing GitHub issue in this repository. If there is no issue yet, open one and reference it in the PR description, for example with `Closes #123`.
## Pull request policy
-Pull requests are welcome and encouraged.
+Pull requests from everyone are welcome and encouraged, including first-time contributors and people who are not maintainers or existing contributors.
-That said, most PRs should be treated as proposals or references, not as changes that are likely to be merged directly. This project is developed with heavy use of code generation, and generated code can be deceptively plausible: it may fix the visible problem while introducing subtle correctness, lifecycle, architecture, or maintenance issues.
+PRs are reviewed as changes that can be merged directly, not merely as proposals or references for a maintainer-authored rewrite. Review is based on correctness, tests, security, architecture, maintainability, and fit with the project, not the author's contributor status.
-Because of that, I will often use PRs to understand the bug, feature request, test case, design direction, or proposed implementation, then write my own version of the change. The submitted code may still be extremely valuable as a reference, reproduction, or proof of concept, even if the final committed code is different.
+AI-assisted and generated contributions are welcome and held to the same standards as handwritten code. Understand the changes you submit, explain their assumptions and tradeoffs, and validate them. This applies equally to maintainer and community contributions.
-This is not a judgment that maintainer-generated code is inherently better than contributor-generated code. It is a practical ownership rule: if I am going to maintain the resulting code, I need to understand its assumptions, tradeoffs, and failure modes.
+Maintainers may request revisions, help refine an implementation, or decline a change that does not fit the project. A rewrite is not required just because a PR comes from an outside contributor.
-The best PRs therefore include:
+The best PRs include:
- a clear description of the problem being solved
- a minimal reproduction or failing test when possible
@@ -26,6 +28,4 @@ The best PRs therefore include:
- focused changes that are easy to review independently
- any relevant logs, screenshots, traces, or benchmarks
-Large, generated, or highly invasive PRs may be closed even when the underlying idea is good. In those cases, the issue or PR may still be used as a reference for a maintainer-authored change.
-
-Handwritten by author: My clanker slop may or may not be better than your clanker slop. I know how to work with my clanker slop though.
+Keep changes focused and split large changes into independently reviewable pieces when possible. Passing checks does not guarantee a merge, but author status or use of code generation is not, by itself, a reason to reject a contribution.
diff --git a/Cargo.lock b/Cargo.lock
index e7c041a661..0b18f7be59 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,34 +3,60 @@
version = 4
[[package]]
-name = "ab_glyph"
-version = "0.2.32"
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "adobe-cmap-parser"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3"
dependencies = [
- "ab_glyph_rasterizer",
- "owned_ttf_parser",
+ "pom",
]
[[package]]
-name = "ab_glyph_rasterizer"
-version = "0.1.10"
+name = "aes"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
[[package]]
-name = "adler2"
-version = "2.0.1"
+name = "agent-client-protocol"
+version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+checksum = "10eeef5e80864f9c3c148a3f395c3e35a66d37ec7561c7845b2bffae8e841759"
+dependencies = [
+ "agent-client-protocol-schema",
+ "anyhow",
+ "async-broadcast",
+ "async-trait",
+ "derive_more",
+ "futures",
+ "log",
+ "serde",
+ "serde_json",
+]
[[package]]
-name = "adobe-cmap-parser"
-version = "0.4.1"
+name = "agent-client-protocol-schema"
+version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3"
+checksum = "ca68e7e55681ce56546c0cecc6bc8f20493d24b44c6d93ec46174f310730bba2"
dependencies = [
- "pom",
+ "anyhow",
+ "derive_more",
+ "schemars",
+ "serde",
+ "serde_json",
+ "strum 0.28.0",
]
[[package]]
@@ -81,33 +107,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
-[[package]]
-name = "android-activity"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289"
-dependencies = [
- "android-properties",
- "bitflags 2.10.0",
- "cc",
- "cesu8",
- "jni 0.21.1",
- "jni-sys 0.3.1",
- "libc",
- "log",
- "ndk",
- "ndk-context",
- "ndk-sys",
- "num_enum",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "android-properties"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
-
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -173,27 +172,12 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
-[[package]]
-name = "anymap2"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
-
[[package]]
name = "anymap3"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25"
-[[package]]
-name = "ar_archive_writer"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
-dependencies = [
- "object",
-]
-
[[package]]
name = "arboard"
version = "3.6.1"
@@ -203,7 +187,7 @@ dependencies = [
"clipboard-win",
"image",
"log",
- "objc2 0.6.3",
+ "objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -211,10 +195,18 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
- "wl-clipboard-rs",
"x11rb",
]
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -228,18 +220,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
-name = "as-raw-xcb-connection"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
-
-[[package]]
-name = "ash"
-version = "0.37.3+1.3.251"
+name = "async-broadcast"
+version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
- "libloading 0.7.4",
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
]
[[package]]
@@ -321,9 +310,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-config"
-version = "1.8.16"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d"
+checksum = "47712fde1909402600ccfbb26e47d482d2e58bb9e9e603d9f17e67cc435a6319"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -335,6 +324,7 @@ dependencies = [
"aws-smithy-json",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"bytes",
@@ -351,9 +341,9 @@ dependencies = [
[[package]]
name = "aws-credential-types"
-version = "1.2.14"
+version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7"
+checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -385,9 +375,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
-version = "1.7.3"
+version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864"
+checksum = "7816e98ee912159f45d307e5ee6bfea4a335a55aee15f7f3e32f81a6f3000f1d"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -411,10 +401,11 @@ dependencies = [
[[package]]
name = "aws-sdk-bedrock"
-version = "1.141.0"
+version = "1.148.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a16484ce62b16cadf941e1c408d9b73afb9e6fc456573e5a9681e38d45fb00a"
+checksum = "3450c366c278d75c4d00b51ad6801b2f85681b7aa3c115a189106d816703e055"
dependencies = [
+ "arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
@@ -423,6 +414,7 @@ dependencies = [
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"bytes",
@@ -435,10 +427,11 @@ dependencies = [
[[package]]
name = "aws-sdk-bedrockruntime"
-version = "1.130.0"
+version = "1.136.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e2f7bca252e3c5c8f0ed12c5501bf8b0fbadb937cd9fdd71a0ebd9d7526540f"
+checksum = "cf9484190cd923402dcc480aa92a582e3c370312d35d5d9564bbcdbbb5b69f8f"
dependencies = [
+ "arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-sigv4",
@@ -449,6 +442,7 @@ dependencies = [
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"bytes",
@@ -462,10 +456,11 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
-version = "1.98.0"
+version = "1.103.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489"
+checksum = "0469f435f645ad2162cfb463b15bde37115966ee3acf2d87fb4871ee309b8401"
dependencies = [
+ "arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
@@ -474,6 +469,7 @@ dependencies = [
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"bytes",
@@ -486,10 +482,11 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
-version = "1.100.0"
+version = "1.105.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225"
+checksum = "085faefb253f770655e162b9304321e62a1e71adf7f019ee1f4454228a377b3a"
dependencies = [
+ "arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
@@ -498,6 +495,7 @@ dependencies = [
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"bytes",
@@ -510,10 +508,11 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
-version = "1.103.0"
+version = "1.108.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b"
+checksum = "3c72b08911d8128dd360fe1b22a9fec0fa8b552dde8ec828dcf20ef5ec974e9f"
dependencies = [
+ "arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
@@ -523,6 +522,7 @@ dependencies = [
"aws-smithy-query",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
"aws-smithy-xml",
"aws-types",
@@ -535,9 +535,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
-version = "1.4.3"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a"
+checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -545,7 +545,7 @@ dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
- "crypto-bigint 0.5.5",
+ "crypto-bigint",
"form_urlencoded",
"hex",
"hmac 0.13.0",
@@ -553,7 +553,6 @@ dependencies = [
"http 1.4.0",
"p256",
"percent-encoding",
- "ring",
"sha2 0.11.0",
"subtle",
"time",
@@ -563,9 +562,9 @@ dependencies = [
[[package]]
name = "aws-smithy-async"
-version = "1.2.14"
+version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc"
+checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316"
dependencies = [
"futures-util",
"pin-project-lite",
@@ -574,9 +573,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
-version = "0.60.20"
+version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548"
+checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
dependencies = [
"aws-smithy-types",
"bytes",
@@ -585,9 +584,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
-version = "0.63.6"
+version = "0.64.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231"
+checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -607,57 +606,53 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
-version = "1.1.12"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769"
+checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
- "h2 0.3.27",
- "h2 0.4.13",
- "http 0.2.12",
+ "h2",
"http 1.4.0",
- "http-body 0.4.6",
- "hyper 0.14.32",
- "hyper 1.8.1",
- "hyper-rustls 0.24.2",
- "hyper-rustls 0.27.7",
+ "hyper",
+ "hyper-rustls",
"hyper-util",
"pin-project-lite",
- "rustls 0.21.12",
- "rustls 0.23.37",
- "rustls-native-certs 0.8.3",
+ "rustls",
+ "rustls-native-certs",
"rustls-pki-types",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"tower",
"tracing",
]
[[package]]
name = "aws-smithy-json"
-version = "0.62.5"
+version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a"
+checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef"
dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
]
[[package]]
name = "aws-smithy-observability"
-version = "0.2.6"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c"
+checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c"
dependencies = [
"aws-smithy-runtime-api",
]
[[package]]
name = "aws-smithy-query"
-version = "0.60.15"
+version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd"
+checksum = "dd22a6ba36e3f113cb8d5b3d1fe0ed31c76ee608ef63322d753bb8d2c9479e77"
dependencies = [
"aws-smithy-types",
"urlencoding",
@@ -665,15 +660,16 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
-version = "1.11.1"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5"
+checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-http-client",
"aws-smithy-observability",
"aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
"bytes",
"fastrand",
@@ -690,9 +686,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
-version = "1.12.0"
+version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e"
+checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -708,20 +704,31 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api-macros"
-version = "1.0.0"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7"
+checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
+[[package]]
+name = "aws-smithy-schema"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910"
+dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "http 1.4.0",
+]
+
[[package]]
name = "aws-smithy-types"
-version = "1.4.7"
+version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c"
+checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
dependencies = [
"base64-simd",
"bytes",
@@ -745,22 +752,26 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
-version = "0.60.15"
+version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3"
+checksum = "ea3f68eec3607f02acd24067969ce2abc6ba16aa7d5ce59ca450ed2fb5f78957"
dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
"xmlparser",
]
[[package]]
name = "aws-types"
-version = "1.3.15"
+version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac"
+checksum = "e957a6c6dbce82b7a91f44231c09273159703769f447cbe85e854dfe9cf67f86"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
"aws-smithy-runtime-api",
+ "aws-smithy-schema",
"aws-smithy-types",
"rustc_version",
"tracing",
@@ -820,9 +831,9 @@ dependencies = [
[[package]]
name = "base16ct"
-version = "0.1.1"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
@@ -867,7 +878,25 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
- "bit-vec",
+ "bit-vec 0.6.3",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec 0.8.0",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
+dependencies = [
+ "bit-vec 0.9.1",
]
[[package]]
@@ -876,6 +905,21 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -903,12 +947,6 @@ dependencies = [
"wyz",
]
-[[package]]
-name = "block"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
-
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -928,32 +966,28 @@ dependencies = [
]
[[package]]
-name = "block-sys"
-version = "0.2.1"
+name = "block-padding"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
- "objc-sys",
+ "generic-array",
]
[[package]]
name = "block2"
-version = "0.3.0"
+version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
- "block-sys",
- "objc2 0.4.1",
+ "objc2",
]
[[package]]
-name = "block2"
-version = "0.6.2"
+name = "borrow-or-share"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
-dependencies = [
- "objc2 0.6.3",
-]
+checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "bstr"
@@ -983,11 +1017,17 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
+[[package]]
+name = "bytecount"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
+
[[package]]
name = "bytemuck"
-version = "1.24.0"
+version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [
"bytemuck_derive",
]
@@ -1032,38 +1072,21 @@ dependencies = [
]
[[package]]
-name = "calloop"
-version = "0.12.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298"
-dependencies = [
- "bitflags 2.10.0",
- "log",
- "polling",
- "rustix 0.38.44",
- "slab",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "calloop-wayland-source"
-version = "0.2.0"
+name = "castaway"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
- "calloop",
- "rustix 0.38.44",
- "wayland-backend",
- "wayland-client",
+ "rustversion",
]
[[package]]
-name = "castaway"
-version = "0.2.4"
+name = "cbc"
+version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
- "rustversion",
+ "cipher",
]
[[package]]
@@ -1078,17 +1101,11 @@ dependencies = [
"shlex",
]
-[[package]]
-name = "cesu8"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
-
[[package]]
name = "cff-parser"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d"
+checksum = "c5810ca1a2b5870df2aab1c03e11c40c361ba51d6e3e361e56310f1cb3b4e087"
[[package]]
name = "cfg-if"
@@ -1096,12 +1113,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-[[package]]
-name = "cfg_aliases"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
-
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -1134,13 +1145,13 @@ dependencies = [
]
[[package]]
-name = "chumsky"
-version = "0.9.3"
+name = "cipher"
+version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
- "hashbrown 0.14.5",
- "stacker",
+ "crypto-common 0.1.7",
+ "inout",
]
[[package]]
@@ -1207,16 +1218,6 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
-[[package]]
-name = "codespan-reporting"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
-dependencies = [
- "termcolor",
- "unicode-width 0.1.14",
-]
-
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -1229,37 +1230,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
-[[package]]
-name = "com"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6"
-dependencies = [
- "com_macros",
-]
-
-[[package]]
-name = "com_macros"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5"
-dependencies = [
- "com_macros_support",
- "proc-macro2",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "com_macros_support"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
[[package]]
name = "combine"
version = "4.6.7"
@@ -1367,7 +1337,7 @@ dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
]
@@ -1391,27 +1361,6 @@ dependencies = [
"libm",
]
-[[package]]
-name = "cosmic-text"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75acbfb314aeb4f5210d379af45ed1ec2c98c7f1790bf57b8a4c562ac0c51b71"
-dependencies = [
- "fontdb 0.15.0",
- "libm",
- "log",
- "rangemap",
- "rustc-hash 1.1.0",
- "rustybuzz 0.11.0",
- "self_cell",
- "swash",
- "sys-locale",
- "unicode-bidi",
- "unicode-linebreak",
- "unicode-script",
- "unicode-segmentation",
-]
-
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1507,26 +1456,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
-[[package]]
-name = "crypto-bigint"
-version = "0.4.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef"
-dependencies = [
- "generic-array",
- "rand_core 0.6.4",
- "subtle",
- "zeroize",
-]
-
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
+ "generic-array",
"rand_core 0.6.4",
"subtle",
+ "zeroize",
]
[[package]]
@@ -1567,23 +1506,6 @@ dependencies = [
"cmov",
]
-[[package]]
-name = "cursor-icon"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
-
-[[package]]
-name = "d3d12"
-version = "0.19.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307"
-dependencies = [
- "bitflags 2.10.0",
- "libloading 0.8.9",
- "winapi",
-]
-
[[package]]
name = "darling"
version = "0.20.11"
@@ -1682,11 +1604,12 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]]
name = "der"
-version = "0.6.1"
+version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
+ "pem-rfc7468",
"zeroize",
]
@@ -1702,13 +1625,13 @@ dependencies = [
[[package]]
name = "derive-new"
-version = "0.5.9"
+version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535"
+checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc"
dependencies = [
"proc-macro2",
"quote",
- "syn 1.0.109",
+ "syn 2.0.117",
]
[[package]]
@@ -1762,6 +1685,7 @@ dependencies = [
"quote",
"rustc_version",
"syn 2.0.117",
+ "unicode-xid",
]
[[package]]
@@ -1771,6 +1695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
+ "const-oid 0.9.6",
"crypto-common 0.1.7",
"subtle",
]
@@ -1808,12 +1733,6 @@ dependencies = [
"windows-sys 0.48.0",
]
-[[package]]
-name = "dispatch"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
-
[[package]]
name = "dispatch2"
version = "0.3.0"
@@ -1821,7 +1740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
- "objc2 0.6.3",
+ "objc2",
]
[[package]]
@@ -1835,21 +1754,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "dlib"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
-dependencies = [
- "libloading 0.8.9",
-]
-
-[[package]]
-name = "doc-comment"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9"
-
[[package]]
name = "document-features"
version = "0.2.12"
@@ -1861,9 +1765,9 @@ dependencies = [
[[package]]
name = "downcast-rs"
-version = "1.2.1"
+version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]]
name = "dunce"
@@ -1877,22 +1781,39 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "dyn-eq"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388"
+
[[package]]
name = "dyn-hash"
-version = "0.2.2"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e"
+
+[[package]]
+name = "ecb"
+version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"
+checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7"
+dependencies = [
+ "cipher",
+]
[[package]]
name = "ecdsa"
-version = "0.14.8"
+version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
+ "digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
+ "spki",
]
[[package]]
@@ -1903,17 +1824,17 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "elliptic-curve"
-version = "0.12.3"
+version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
- "crypto-bigint 0.4.9",
- "der",
+ "crypto-bigint",
"digest 0.10.7",
"ff",
"generic-array",
"group",
+ "pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
@@ -1936,6 +1857,9 @@ name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+dependencies = [
+ "serde",
+]
[[package]]
name = "encoding_rs"
@@ -1952,6 +1876,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
[[package]]
name = "errno"
version = "0.3.14"
@@ -1974,16 +1909,6 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
-[[package]]
-name = "etagere"
-version = "0.2.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342"
-dependencies = [
- "euclid 0.22.13",
- "svg_fmt",
-]
-
[[package]]
name = "euclid"
version = "0.20.14"
@@ -1995,9 +1920,9 @@ dependencies = [
[[package]]
name = "euclid"
-version = "0.22.13"
+version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
@@ -2023,16 +1948,39 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
[[package]]
name = "fancy-regex"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
dependencies = [
- "bit-set",
+ "bit-set 0.5.3",
"regex",
]
+[[package]]
+name = "fancy-regex"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277"
+dependencies = [
+ "bit-set 0.8.0",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "fast-srgb8"
version = "1.0.0"
@@ -2076,9 +2024,9 @@ dependencies = [
[[package]]
name = "ff"
-version = "0.12.1"
+version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
@@ -2124,12 +2072,6 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
-[[package]]
-name = "fixedbitset"
-version = "0.5.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
-
[[package]]
name = "flate2"
version = "1.1.8"
@@ -2146,6 +2088,23 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
+[[package]]
+name = "float-ord"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
+
+[[package]]
+name = "fluent-uri"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
+dependencies = [
+ "borrow-or-share",
+ "ref-cast",
+ "serde",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2164,15 +2123,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
-[[package]]
-name = "font-types"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492"
-dependencies = [
- "bytemuck",
-]
-
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
@@ -2184,30 +2134,25 @@ dependencies = [
[[package]]
name = "fontdb"
-version = "0.15.0"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020e203f177c0fb250fb19455a252e838d2bbbce1f80f25ecc42402aafa8cd38"
+checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"fontconfig-parser",
"log",
- "memmap2 0.8.0",
+ "memmap2",
"slotmap",
"tinyvec",
- "ttf-parser 0.19.2",
+ "ttf-parser",
]
[[package]]
-name = "fontdb"
-version = "0.23.0"
+name = "foreign-types"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
- "fontconfig-parser",
- "log",
- "memmap2 0.9.9",
- "slotmap",
- "tinyvec",
- "ttf-parser 0.25.1",
+ "foreign-types-shared 0.1.1",
]
[[package]]
@@ -2217,7 +2162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
- "foreign-types-shared",
+ "foreign-types-shared 0.3.1",
]
[[package]]
@@ -2231,6 +2176,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2246,6 +2197,16 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fraction"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
+dependencies = [
+ "lazy_static",
+ "num",
+]
+
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -2355,6 +2316,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -2373,7 +2335,7 @@ version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
- "unicode-width 0.2.2",
+ "unicode-width",
]
[[package]]
@@ -2410,11 +2372,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
+ "wasm-bindgen",
]
[[package]]
@@ -2427,17 +2391,6 @@ dependencies = [
"weezl",
]
-[[package]]
-name = "gl_generator"
-version = "0.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
-dependencies = [
- "khronos_api",
- "log",
- "xml-rs",
-]
-
[[package]]
name = "glob"
version = "0.3.3"
@@ -2452,10 +2405,10 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
- "objc2 0.6.3",
+ "objc2",
"objc2-app-kit",
"once_cell",
- "thiserror 2.0.17",
+ "thiserror 2.0.19",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
@@ -2474,121 +2427,17 @@ dependencies = [
"regex-syntax",
]
-[[package]]
-name = "glow"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1"
-dependencies = [
- "js-sys",
- "slotmap",
- "wasm-bindgen",
- "web-sys",
-]
-
-[[package]]
-name = "glutin_wgl_sys"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead"
-dependencies = [
- "gl_generator",
-]
-
-[[package]]
-name = "glyphon"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a62d0338e4056db6a73221c2fb2e30619452f6ea9651bac4110f51b0f7a7581"
-dependencies = [
- "cosmic-text",
- "etagere",
- "lru 0.12.5",
- "wgpu",
-]
-
-[[package]]
-name = "gpu-alloc"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171"
-dependencies = [
- "bitflags 2.10.0",
- "gpu-alloc-types",
-]
-
-[[package]]
-name = "gpu-alloc-types"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4"
-dependencies = [
- "bitflags 2.10.0",
-]
-
-[[package]]
-name = "gpu-allocator"
-version = "0.25.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884"
-dependencies = [
- "log",
- "presser",
- "thiserror 1.0.69",
- "winapi",
- "windows 0.52.0",
-]
-
-[[package]]
-name = "gpu-descriptor"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c"
-dependencies = [
- "bitflags 2.10.0",
- "gpu-descriptor-types",
- "hashbrown 0.14.5",
-]
-
-[[package]]
-name = "gpu-descriptor-types"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c"
-dependencies = [
- "bitflags 2.10.0",
-]
-
[[package]]
name = "group"
-version = "0.12.1"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
-[[package]]
-name = "h2"
-version = "0.3.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
-dependencies = [
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "futures-util",
- "http 0.2.12",
- "indexmap",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
[[package]]
name = "h2"
version = "0.4.13"
@@ -2627,7 +2476,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
- "allocator-api2",
]
[[package]]
@@ -2636,8 +2484,6 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
- "allocator-api2",
- "equivalent",
"foldhash 0.1.5",
]
@@ -2650,21 +2496,28 @@ dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
+ "serde",
+ "serde_core",
]
[[package]]
-name = "hassle-rs"
-version = "0.11.0"
+name = "hashbrown"
+version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
- "bitflags 2.10.0",
- "com",
- "libc",
- "libloading 0.8.9",
- "thiserror 1.0.69",
- "widestring",
- "winapi",
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown 0.14.5",
]
[[package]]
@@ -2679,24 +2532,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-[[package]]
-name = "hermit-abi"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
-
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-[[package]]
-name = "hexf-parse"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
-
[[package]]
name = "hmac"
version = "0.12.1"
@@ -2791,30 +2632,6 @@ dependencies = [
"typenum",
]
-[[package]]
-name = "hyper"
-version = "0.14.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
-dependencies = [
- "bytes",
- "futures-channel",
- "futures-core",
- "futures-util",
- "h2 0.3.27",
- "http 0.2.12",
- "http-body 0.4.6",
- "httparse",
- "httpdate",
- "itoa",
- "pin-project-lite",
- "socket2 0.5.10",
- "tokio",
- "tower-service",
- "tracing",
- "want",
-]
-
[[package]]
name = "hyper"
version = "1.8.1"
@@ -2825,7 +2642,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2 0.4.13",
+ "h2",
"http 1.4.0",
"http-body 1.0.1",
"httparse",
@@ -2839,33 +2656,18 @@ dependencies = [
[[package]]
name = "hyper-rustls"
-version = "0.24.2"
+version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
-dependencies = [
- "futures-util",
- "http 0.2.12",
- "hyper 0.14.32",
- "log",
- "rustls 0.21.12",
- "tokio",
- "tokio-rustls 0.24.1",
-]
-
-[[package]]
-name = "hyper-rustls"
-version = "0.27.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.4.0",
- "hyper 1.8.1",
+ "hyper",
"hyper-util",
- "rustls 0.23.37",
- "rustls-native-certs 0.8.3",
+ "rustls",
+ "rustls-native-certs",
"rustls-pki-types",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"tower-service",
"webpki-roots",
]
@@ -2883,12 +2685,12 @@ dependencies = [
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
- "hyper 1.8.1",
+ "hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.6.1",
+ "socket2",
"system-configuration",
"tokio",
"tower-service",
@@ -2920,17 +2722,6 @@ dependencies = [
"cc",
]
-[[package]]
-name = "icrate"
-version = "0.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319"
-dependencies = [
- "block2 0.3.0",
- "dispatch",
- "objc2 0.4.1",
-]
-
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -3019,7 +2810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5"
dependencies = [
"quantette",
- "thiserror 2.0.17",
+ "thiserror 2.0.19",
]
[[package]]
@@ -3081,7 +2872,7 @@ dependencies = [
"byteorder-lite",
"moxcms",
"num-traits",
- "png 0.18.0",
+ "png 0.18.1",
"tiff",
"zune-core 0.5.1",
"zune-jpeg 0.5.11",
@@ -3114,10 +2905,10 @@ dependencies = [
"chrono",
"imap-proto",
"lazy_static",
+ "native-tls",
"nom 7.1.3",
"ouroboros",
"regex",
- "rustls-connector",
]
[[package]]
@@ -3150,6 +2941,16 @@ dependencies = [
"rustversion",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "block-padding",
+ "generic-array",
+]
+
[[package]]
name = "instability"
version = "0.3.11"
@@ -3163,6 +2964,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "inventory"
+version = "0.3.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -3204,33 +3014,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
-[[package]]
-name = "itertools"
-version = "0.10.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itertools"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itertools"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
-dependencies = [
- "either",
-]
-
[[package]]
name = "itertools"
version = "0.14.0"
@@ -3248,12 +3031,12 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jcode"
-version = "0.50.0"
+version = "0.84.0"
dependencies = [
"anyhow",
"async-stream",
"async-trait",
- "block2 0.6.2",
+ "block2",
"chrono",
"clap",
"crossterm",
@@ -3262,26 +3045,32 @@ dependencies = [
"global-hotkey",
"hex",
"jcode-build-meta",
+ "jcode-harness-api",
+ "jcode-harness-api-server",
"jcode-provider-anthropic-runtime",
"jcode-provider-antigravity-runtime",
"jcode-provider-claude-cli-runtime",
"jcode-provider-copilot-runtime",
+ "jcode-provider-core",
"jcode-provider-cursor-runtime",
"jcode-provider-doctor",
"jcode-provider-gemini-runtime",
+ "jcode-provider-grok-build-runtime",
"jcode-provider-openai-runtime",
"jcode-provider-openrouter-runtime",
"jcode-selfdev-types",
"jcode-tui",
"jcode-tui-session-picker",
+ "jcode-tui-style",
"libc",
- "objc2 0.6.3",
+ "objc2",
"objc2-app-kit",
"objc2-foundation",
+ "objc2-user-notifications",
"open",
"ratatui",
"reqwest 0.12.28",
- "rustls 0.23.37",
+ "rustls",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -3332,6 +3121,7 @@ dependencies = [
"jcode-base",
"jcode-build-meta",
"jcode-build-support",
+ "jcode-command-risk",
"jcode-core",
"jcode-import-core",
"jcode-message-types",
@@ -3340,6 +3130,7 @@ dependencies = [
"jcode-pdf",
"jcode-plan",
"jcode-provider-core",
+ "jcode-schema-dialect",
"jcode-selfdev-types",
"jcode-session-types",
"jcode-setup-hints",
@@ -3364,7 +3155,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-util",
- "unicode-width 0.2.2",
+ "unicode-width",
"url",
"urlencoding",
"uuid",
@@ -3445,6 +3236,7 @@ dependencies = [
"jcode-terminal-launch",
"jcode-tool-core",
"jcode-tool-types",
+ "jcode-transport",
"jcode-usage-types",
"libc",
"open",
@@ -3453,6 +3245,7 @@ dependencies = [
"rand 0.9.3",
"regex",
"reqwest 0.12.28",
+ "rusqlite",
"serde",
"serde_json",
"serde_yaml",
@@ -3495,6 +3288,13 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "jcode-command-risk"
+version = "0.1.0"
+dependencies = [
+ "tempfile",
+]
+
[[package]]
name = "jcode-compaction-core"
version = "0.1.0"
@@ -3518,32 +3318,11 @@ dependencies = [
"chrono",
"libc",
"rand 0.9.3",
+ "unicode-properties",
+ "unicode-segmentation",
"windows-sys 0.59.0",
]
-[[package]]
-name = "jcode-desktop"
-version = "0.1.0"
-dependencies = [
- "ab_glyph",
- "anyhow",
- "arboard",
- "base64 0.22.1",
- "bytemuck",
- "glyphon",
- "image",
- "jcode-fuzzy",
- "jcode-tui-messages",
- "libc",
- "pollster",
- "pulldown-cmark",
- "serde",
- "serde_json",
- "wgpu",
- "whoami",
- "winit",
-]
-
[[package]]
name = "jcode-embedding"
version = "0.1.0"
@@ -3566,6 +3345,31 @@ dependencies = [
"serde",
]
+[[package]]
+name = "jcode-harness-api"
+version = "0.1.0"
+dependencies = [
+ "jcode-usage-types",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "jcode-harness-api-server"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "jcode-background-types",
+ "jcode-base",
+ "jcode-harness-api",
+ "jcode-transport",
+ "libc",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
[[package]]
name = "jcode-import-core"
version = "0.1.0"
@@ -3617,6 +3421,7 @@ dependencies = [
"imap",
"lettre",
"mail-parser",
+ "native-tls",
"pulldown-cmark",
"urlencoding",
]
@@ -3686,6 +3491,7 @@ dependencies = [
"jcode-logging",
"jcode-message-types",
"jcode-provider-core",
+ "jcode-schema-dialect",
"serde",
"serde_json",
]
@@ -3717,6 +3523,7 @@ version = "0.1.0"
dependencies = [
"chrono",
"jcode-provider-gemini",
+ "jcode-schema-dialect",
"serde",
"serde_json",
]
@@ -3733,6 +3540,7 @@ dependencies = [
"jcode-provider-antigravity",
"jcode-provider-core",
"jcode-provider-gemini",
+ "jcode-schema-dialect",
"reqwest 0.12.28",
"serde_json",
"tempfile",
@@ -3791,6 +3599,7 @@ name = "jcode-provider-copilot"
version = "0.1.0"
dependencies = [
"jcode-message-types",
+ "jcode-schema-dialect",
"serde",
"serde_json",
]
@@ -3808,6 +3617,7 @@ dependencies = [
"jcode-message-types",
"jcode-provider-copilot",
"jcode-provider-core",
+ "jcode-provider-openai",
"reqwest 0.12.28",
"serde_json",
"tempfile",
@@ -3826,6 +3636,8 @@ dependencies = [
"httpdate",
"jcode-logging",
"jcode-message-types",
+ "jcode-schema-dialect",
+ "jcode-usage-types",
"rand 0.9.3",
"reqwest 0.12.28",
"serde",
@@ -3843,7 +3655,7 @@ dependencies = [
"bytes",
"chrono",
"flate2",
- "h2 0.4.13",
+ "h2",
"http 1.4.0",
"jcode-base",
"jcode-message-types",
@@ -3853,7 +3665,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"tokio-stream",
"uuid",
"webpki-roots",
@@ -3898,6 +3710,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"jcode-message-types",
+ "jcode-schema-dialect",
"serde",
"serde_json",
]
@@ -3911,8 +3724,13 @@ dependencies = [
"chrono",
"jcode-base",
"jcode-message-types",
+ "jcode-provider-anthropic",
+ "jcode-provider-antigravity",
"jcode-provider-core",
"jcode-provider-gemini",
+ "jcode-provider-openai",
+ "jcode-provider-openrouter",
+ "jcode-schema-dialect",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -3922,6 +3740,23 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "jcode-provider-grok-build-runtime"
+version = "0.1.0"
+dependencies = [
+ "agent-client-protocol",
+ "anyhow",
+ "async-trait",
+ "futures",
+ "jcode-message-types",
+ "jcode-provider-core",
+ "serde_json",
+ "tempfile",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+]
+
[[package]]
name = "jcode-provider-metadata"
version = "0.1.0"
@@ -3960,7 +3795,9 @@ dependencies = [
"jcode-message-types",
"jcode-provider-core",
"jcode-provider-openai",
+ "jcode-schema-dialect",
"reqwest 0.12.28",
+ "rustls",
"serde_json",
"tempfile",
"tokio",
@@ -3979,6 +3816,7 @@ dependencies = [
"jcode-core",
"jcode-logging",
"jcode-message-types",
+ "jcode-schema-dialect",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -4004,6 +3842,7 @@ dependencies = [
"tokio",
"tokio-stream",
"toml",
+ "uuid",
]
[[package]]
@@ -4012,7 +3851,34 @@ version = "0.1.0"
dependencies = [
"pulldown-cmark",
"serde",
- "unicode-width 0.2.2",
+ "unicode-width",
+]
+
+[[package]]
+name = "jcode-schema-dialect"
+version = "0.1.0"
+dependencies = [
+ "dirs",
+ "jcode-schema-dialect",
+ "serde",
+ "serde_json",
+ "tempfile",
+]
+
+[[package]]
+name = "jcode-sdk"
+version = "0.1.0"
+dependencies = [
+ "jcode-harness-api",
+ "jcode-provider-metadata",
+ "jcode-transport",
+ "jsonschema",
+ "libc",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "url",
+ "uuid",
]
[[package]]
@@ -4043,6 +3909,7 @@ dependencies = [
"global-hotkey",
"jcode-build-meta",
"jcode-config-types",
+ "jcode-core",
"jcode-logging",
"jcode-storage",
"jcode-terminal-launch",
@@ -4088,6 +3955,7 @@ version = "0.1.0"
dependencies = [
"chrono",
"serde",
+ "serde_json",
]
[[package]]
@@ -4146,6 +4014,16 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "jcode-transport"
+version = "0.1.0"
+dependencies = [
+ "hex",
+ "sha2 0.10.9",
+ "tokio",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "jcode-tui"
version = "0.1.0"
@@ -4198,7 +4076,7 @@ dependencies = [
"tempfile",
"terminal-colorsaurus",
"tokio",
- "unicode-width 0.2.2",
+ "unicode-width",
"url",
"urlencoding",
]
@@ -4236,13 +4114,14 @@ dependencies = [
"jcode-render-core",
"jcode-tui-mermaid",
"jcode-tui-workspace",
+ "mdwright-latex",
"pulldown-cmark",
"ratatui",
"serde",
"serde_json",
"syntect",
"tempfile",
- "unicode-width 0.2.2",
+ "unicode-width",
"wait-timeout",
]
@@ -4299,7 +4178,7 @@ dependencies = [
"chrono",
"jcode-tui-style",
"ratatui",
- "unicode-width 0.2.2",
+ "unicode-width",
]
[[package]]
@@ -4316,6 +4195,7 @@ dependencies = [
name = "jcode-tui-style"
version = "0.1.0"
dependencies = [
+ "jcode-logging",
"ratatui",
]
@@ -4323,7 +4203,7 @@ dependencies = [
name = "jcode-tui-tool-display"
version = "0.1.0"
dependencies = [
- "unicode-width 0.2.2",
+ "unicode-width",
]
[[package]]
@@ -4375,22 +4255,6 @@ dependencies = [
"serde_json",
]
-[[package]]
-name = "jni"
-version = "0.21.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
-dependencies = [
- "cesu8",
- "cfg-if",
- "combine",
- "jni-sys 0.3.1",
- "log",
- "thiserror 1.0.69",
- "walkdir",
- "windows-sys 0.45.0",
-]
-
[[package]]
name = "jni"
version = "0.22.4"
@@ -4400,10 +4264,10 @@ dependencies = [
"cfg-if",
"combine",
"jni-macros",
- "jni-sys 0.4.1",
+ "jni-sys",
"log",
"simd_cesu8",
- "thiserror 2.0.17",
+ "thiserror 2.0.19",
"walkdir",
"windows-link",
]
@@ -4421,15 +4285,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "jni-sys"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
-dependencies = [
- "jni-sys 0.4.1",
-]
-
[[package]]
name = "jni-sys"
version = "0.4.1"
@@ -4482,62 +4337,88 @@ dependencies = [
]
[[package]]
-name = "kasuari"
-version = "0.4.12"
+name = "jsonschema"
+version = "0.49.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
+checksum = "508004a5500f2e1f68af048f70feea2de86d35ab115d85716530860822aef397"
dependencies = [
- "hashbrown 0.16.1",
- "portable-atomic",
- "thiserror 2.0.17",
+ "ahash",
+ "bytecount",
+ "data-encoding",
+ "email_address",
+ "fancy-regex 0.18.0",
+ "fraction",
+ "getrandom 0.3.4",
+ "idna",
+ "itoa",
+ "jsonschema-regex",
+ "jsonschema-value",
+ "num-cmp",
+ "num-traits",
+ "percent-encoding",
+ "referencing",
+ "regex",
+ "serde",
+ "serde_json",
+ "strum 0.28.0",
+ "unicode-general-category",
+ "uuid-simd",
]
[[package]]
-name = "keyboard-types"
-version = "0.7.0"
+name = "jsonschema-regex"
+version = "0.49.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+checksum = "5a8b30cafa78358ae6cd1494a7d6410b89530e28bf567f862c869c667e900d9f"
dependencies = [
- "bitflags 2.10.0",
- "serde",
- "unicode-segmentation",
+ "regex-syntax",
]
[[package]]
-name = "khronos-egl"
-version = "6.0.0"
+name = "jsonschema-value"
+version = "0.49.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+checksum = "5526bd381d230af94908d07e6835a33fd82a465e12f5f1e9c81f5c2aa23b3c21"
dependencies = [
- "libc",
- "libloading 0.8.9",
- "pkg-config",
+ "ahash",
+ "bytecount",
+ "fraction",
+ "num-cmp",
+ "num-traits",
+ "serde_json",
]
[[package]]
-name = "khronos_api"
-version = "3.1.0"
+name = "kasuari"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
+dependencies = [
+ "hashbrown 0.16.1",
+ "portable-atomic",
+ "thiserror 2.0.19",
+]
[[package]]
-name = "kstring"
-version = "2.0.2"
+name = "keyboard-types"
+version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
+ "bitflags 2.10.0",
"serde",
- "static_assertions",
+ "unicode-segmentation",
]
[[package]]
name = "kurbo"
-version = "0.13.0"
+version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
+checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
- "euclid 0.22.13",
+ "euclid 0.22.14",
+ "polycool",
"smallvec",
]
@@ -4561,13 +4442,12 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
-version = "0.11.19"
+version = "0.11.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
+checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
dependencies = [
"async-trait",
"base64 0.22.1",
- "chumsky",
"email-encoding",
"email_address",
"fastrand",
@@ -4579,10 +4459,10 @@ dependencies = [
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
- "rustls 0.23.37",
- "socket2 0.6.1",
+ "rustls",
+ "socket2",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"url",
"webpki-roots",
]
@@ -4593,26 +4473,6 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
-[[package]]
-name = "libloading"
-version = "0.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
-dependencies = [
- "cfg-if",
- "winapi",
-]
-
-[[package]]
-name = "libloading"
-version = "0.8.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
-dependencies = [
- "cfg-if",
- "windows-link",
-]
-
[[package]]
name = "libm"
version = "0.2.15"
@@ -4630,6 +4490,17 @@ dependencies = [
"redox_syscall 0.7.0",
]
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "line-clipping"
version = "0.3.5"
@@ -4652,116 +4523,91 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
-name = "liquid"
-version = "0.26.8"
+name = "litemap"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e9338405fdbc0bce9b01695b2a2ef6b20eca5363f385d47bce48ddf8323cc25"
-dependencies = [
- "doc-comment",
- "liquid-core",
- "liquid-derive",
- "liquid-lib",
- "serde",
-]
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
-name = "liquid-core"
-version = "0.26.8"
+name = "litrs"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "feb8fed70857010ed9016ed2ce5a7f34e7cc51d5d7255c9c9dc2e3243e490b42"
-dependencies = [
- "anymap2",
- "itertools 0.13.0",
- "kstring",
- "liquid-derive",
- "num-traits",
- "pest",
- "pest_derive",
- "regex",
- "serde",
- "time",
-]
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
-name = "liquid-derive"
-version = "0.26.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b51f1d220e3fa869e24cfd75915efe3164bd09bb11b3165db3f37f57bf673e3"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "liquid-lib"
-version = "0.26.8"
+name = "lock_api"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee1794b5605e9f8864a8a4f41aa97976b42512cc81093f8c885d29fb94c6c556"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
- "itertools 0.13.0",
- "liquid-core",
- "once_cell",
- "percent-encoding",
- "regex",
- "time",
- "unicode-segmentation",
+ "scopeguard",
]
[[package]]
-name = "litemap"
-version = "0.8.1"
+name = "log"
+version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
-name = "litrs"
-version = "1.0.0"
+name = "logos"
+version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
+dependencies = [
+ "logos-derive",
+]
[[package]]
-name = "lock_api"
-version = "0.4.14"
+name = "logos-codegen"
+version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
dependencies = [
- "scopeguard",
+ "fnv",
+ "proc-macro2",
+ "quote",
+ "regex-automata",
+ "regex-syntax",
+ "syn 2.0.117",
]
[[package]]
-name = "log"
-version = "0.4.29"
+name = "logos-derive"
+version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
+dependencies = [
+ "logos-codegen",
+]
[[package]]
name = "lopdf"
-version = "0.34.0"
+version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff"
+checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c"
dependencies = [
+ "aes",
+ "bitflags 2.10.0",
+ "cbc",
+ "ecb",
"encoding_rs",
"flate2",
+ "getrandom 0.4.1",
"indexmap",
"itoa",
"log",
"md-5",
- "nom 7.1.3",
+ "nom 8.0.0",
+ "rand 0.10.1",
"rangemap",
- "time",
+ "sha2 0.10.9",
+ "stringprep",
+ "thiserror 2.0.19",
+ "ttf-parser",
"weezl",
]
-[[package]]
-name = "lru"
-version = "0.12.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
-dependencies = [
- "hashbrown 0.15.5",
-]
-
[[package]]
name = "lru"
version = "0.16.3"
@@ -4812,15 +4658,6 @@ dependencies = [
"encoding_rs",
]
-[[package]]
-name = "malloc_buf"
-version = "0.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "maplit"
version = "1.0.2"
@@ -4848,25 +4685,27 @@ dependencies = [
]
[[package]]
-name = "memchr"
-version = "2.7.6"
+name = "mdwright-latex"
+version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+checksum = "8f0cbb1d26a0d205c963a29220d71e23133836ead2ac344896a86d95c204cfba"
+dependencies = [
+ "logos",
+ "unicode-normalization",
+ "unicode-width",
+]
[[package]]
-name = "memmap2"
-version = "0.8.0"
+name = "memchr"
+version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed"
-dependencies = [
- "libc",
-]
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "memmap2"
-version = "0.9.9"
+version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
@@ -4877,6 +4716,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
+[[package]]
+name = "memo-map"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
+
[[package]]
name = "memoffset"
version = "0.9.1"
@@ -4893,32 +4738,23 @@ source = "git+https://github.com/1jehuang/mermaid-rs-renderer.git?tag=v0.3.1#2f9
dependencies = [
"anyhow",
"clap",
- "fontdb 0.23.0",
+ "fontdb",
"json5",
"once_cell",
"regex",
"resvg 0.47.0",
"serde",
"serde_json",
- "thiserror 2.0.17",
- "ttf-parser 0.25.1",
+ "thiserror 2.0.19",
+ "ttf-parser",
"usvg 0.47.0",
]
[[package]]
-name = "metal"
-version = "0.27.0"
+name = "micromap"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25"
-dependencies = [
- "bitflags 2.10.0",
- "block",
- "core-graphics-types",
- "foreign-types",
- "log",
- "objc",
- "paste",
-]
+checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
name = "mime"
@@ -4926,6 +4762,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "minijinja"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
+dependencies = [
+ "memo-map",
+ "serde",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -4987,30 +4833,27 @@ dependencies = [
]
[[package]]
-name = "naga"
-version = "0.19.2"
+name = "native-tls"
+version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
- "bit-set",
- "bitflags 2.10.0",
- "codespan-reporting",
- "hexf-parse",
- "indexmap",
+ "libc",
"log",
- "num-traits",
- "rustc-hash 1.1.0",
- "spirv",
- "termcolor",
- "thiserror 1.0.69",
- "unicode-xid",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
]
[[package]]
name = "ndarray"
-version = "0.16.1"
+version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
@@ -5021,36 +4864,6 @@ dependencies = [
"rawpointer",
]
-[[package]]
-name = "ndk"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
-dependencies = [
- "bitflags 2.10.0",
- "jni-sys 0.3.1",
- "log",
- "ndk-sys",
- "num_enum",
- "raw-window-handle",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "ndk-context"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
-
-[[package]]
-name = "ndk-sys"
-version = "0.5.0+25.2.9519653"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
-dependencies = [
- "jni-sys 0.3.1",
-]
-
[[package]]
name = "nix"
version = "0.29.0"
@@ -5059,7 +4872,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
- "cfg_aliases 0.2.1",
+ "cfg_aliases",
"libc",
"memoffset",
]
@@ -5083,6 +4896,45 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "nom-language"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29"
+dependencies = [
+ "nom 8.0.0",
+]
+
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-cmp"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
+
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -5119,35 +4971,34 @@ dependencies = [
]
[[package]]
-name = "num-traits"
-version = "0.2.19"
+name = "num-iter"
+version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
- "autocfg",
- "libm",
+ "num-integer",
+ "num-traits",
]
[[package]]
-name = "num_enum"
-version = "0.7.6"
+name = "num-rational"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
- "num_enum_derive",
- "rustversion",
+ "num-bigint",
+ "num-integer",
+ "num-traits",
]
[[package]]
-name = "num_enum_derive"
-version = "0.7.6"
+name = "num-traits"
+version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
- "proc-macro-crate",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
+ "autocfg",
+ "libm",
]
[[package]]
@@ -5159,39 +5010,13 @@ dependencies = [
"libc",
]
-[[package]]
-name = "objc"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
-dependencies = [
- "malloc_buf",
- "objc_exception",
-]
-
-[[package]]
-name = "objc-sys"
-version = "0.3.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
-
[[package]]
name = "objc2"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d"
-dependencies = [
- "objc-sys",
- "objc2-encode 3.0.0",
-]
-
-[[package]]
-name = "objc2"
-version = "0.6.3"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
- "objc2-encode 4.1.0",
+ "objc2-encode",
]
[[package]]
@@ -5201,9 +5026,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.10.0",
- "block2 0.6.2",
+ "block2",
"libc",
- "objc2 0.6.3",
+ "objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
@@ -5222,7 +5047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.10.0",
- "objc2 0.6.3",
+ "objc2",
"objc2-foundation",
]
@@ -5233,7 +5058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.10.0",
- "objc2 0.6.3",
+ "objc2",
"objc2-foundation",
]
@@ -5245,7 +5070,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.10.0",
"dispatch2",
- "objc2 0.6.3",
+ "objc2",
]
[[package]]
@@ -5256,7 +5081,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.10.0",
"dispatch2",
- "objc2 0.6.3",
+ "objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
@@ -5267,7 +5092,17 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
- "objc2 0.6.3",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
"objc2-foundation",
]
@@ -5278,7 +5113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.10.0",
- "objc2 0.6.3",
+ "objc2",
"objc2-core-foundation",
"objc2-core-graphics",
]
@@ -5290,18 +5125,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.10.0",
- "objc2 0.6.3",
+ "objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
-[[package]]
-name = "objc2-encode"
-version = "3.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666"
-
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -5315,9 +5144,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.10.0",
- "block2 0.6.2",
+ "block2",
"libc",
- "objc2 0.6.3",
+ "objc2",
"objc2-core-foundation",
]
@@ -5328,7 +5157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.10.0",
- "objc2 0.6.3",
+ "objc2",
"objc2-core-foundation",
]
@@ -5339,26 +5168,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
- "objc2 0.6.3",
+ "objc2",
"objc2-foundation",
]
[[package]]
-name = "objc_exception"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4"
-dependencies = [
- "cc",
-]
-
-[[package]]
-name = "object"
-version = "0.37.3"
+name = "objc2-user-notifications"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
- "memchr",
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-core-location",
+ "objc2-foundation",
]
[[package]]
@@ -5407,10 +5231,29 @@ dependencies = [
]
[[package]]
-name = "openssl-probe"
-version = "0.1.6"
+name = "openssl"
+version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
+dependencies = [
+ "bitflags 2.10.0",
+ "cfg-if",
+ "foreign-types 0.3.2",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
[[package]]
name = "openssl-probe"
@@ -5419,21 +5262,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
-name = "option-ext"
-version = "0.2.0"
+name = "openssl-src"
+version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
+dependencies = [
+ "cc",
+]
[[package]]
-name = "orbclient"
-version = "0.3.53"
+name = "openssl-sys"
+version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12c6933ddbbd16539a7672e697bb8d41ac3a4e99ac43eeb40c07236bd7fcb2dd"
+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
+ "cc",
"libc",
- "libredox",
+ "openssl-src",
+ "pkg-config",
+ "vcpkg",
]
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
[[package]]
name = "ordered-float"
version = "4.6.0"
@@ -5452,16 +5307,6 @@ dependencies = [
"num-traits",
]
-[[package]]
-name = "os_pipe"
-version = "1.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
[[package]]
name = "ouroboros"
version = "0.18.5"
@@ -5492,23 +5337,15 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
-[[package]]
-name = "owned_ttf_parser"
-version = "0.25.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
-dependencies = [
- "ttf-parser 0.25.1",
-]
-
[[package]]
name = "p256"
-version = "0.11.1"
+version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
+ "primeorder",
"sha2 0.10.9",
]
@@ -5571,6 +5408,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+[[package]]
+name = "pastey"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -5579,20 +5422,30 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pdf-extract"
-version = "0.8.2"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87aa267a18864f2f75471f6d316ea430f13e78f0b5a882ce261ebbdfd389a76a"
+checksum = "417e8fdc940f1d5bc62c5f89864c3a2255f74f69aa353c98509213d67df61e73"
dependencies = [
"adobe-cmap-parser",
"cff-parser",
"encoding_rs",
"euclid 0.20.14",
+ "log",
"lopdf",
"postscript",
"type1-encoding-parser",
"unicode-normalization",
]
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -5642,17 +5495,6 @@ dependencies = [
"sha2 0.10.9",
]
-[[package]]
-name = "petgraph"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
-dependencies = [
- "fixedbitset 0.5.7",
- "hashbrown 0.15.5",
- "indexmap",
-]
-
[[package]]
name = "phf"
version = "0.11.3"
@@ -5745,9 +5587,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs8"
-version = "0.9.0"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
@@ -5774,9 +5616,9 @@ dependencies = [
[[package]]
name = "png"
-version = "0.18.0"
+version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.10.0",
"crc32fast",
@@ -5786,25 +5628,14 @@ dependencies = [
]
[[package]]
-name = "polling"
-version = "3.11.0"
+name = "polycool"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
- "cfg-if",
- "concurrent-queue",
- "hermit-abi",
- "pin-project-lite",
- "rustix 1.1.3",
- "windows-sys 0.61.2",
+ "arrayvec",
]
-[[package]]
-name = "pollster"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
-
[[package]]
name = "pom"
version = "1.1.0"
@@ -5856,12 +5687,6 @@ dependencies = [
"zerocopy",
]
-[[package]]
-name = "presser"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
-
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -5882,12 +5707,12 @@ dependencies = [
]
[[package]]
-name = "proc-macro-crate"
-version = "3.5.0"
+name = "primeorder"
+version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
- "toml_edit 0.25.11+spec-1.1.0",
+ "elliptic-curve",
]
[[package]]
@@ -5923,17 +5748,11 @@ dependencies = [
"winapi",
]
-[[package]]
-name = "profiling"
-version = "1.0.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
-
[[package]]
name = "prost"
-version = "0.11.9"
+version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd"
+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
@@ -5941,25 +5760,15 @@ dependencies = [
[[package]]
name = "prost-derive"
-version = "0.11.9"
+version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4"
+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
- "itertools 0.10.5",
+ "itertools",
"proc-macro2",
"quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "psm"
-version = "0.1.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
-dependencies = [
- "ar_archive_writer",
- "cc",
+ "syn 2.0.117",
]
[[package]]
@@ -6022,15 +5831,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
-[[package]]
-name = "quick-xml"
-version = "0.39.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "quinn"
version = "0.11.9"
@@ -6038,17 +5838,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
- "cfg_aliases 0.2.1",
+ "cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
- "rustc-hash 2.1.2",
- "rustls 0.23.37",
- "socket2 0.6.1",
- "thiserror 2.0.17",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.19",
"tokio",
"tracing",
- "web-time 1.1.0",
+ "web-time",
]
[[package]]
@@ -6063,14 +5863,14 @@ dependencies = [
"lru-slab",
"rand 0.9.3",
"ring",
- "rustc-hash 2.1.2",
- "rustls 0.23.37",
+ "rustc-hash",
+ "rustls",
"rustls-pki-types",
"slab",
- "thiserror 2.0.17",
+ "thiserror 2.0.19",
"tinyvec",
"tracing",
- "web-time 1.1.0",
+ "web-time",
]
[[package]]
@@ -6079,10 +5879,10 @@ version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
- "cfg_aliases 0.2.1",
+ "cfg_aliases",
"libc",
"once_cell",
- "socket2 0.6.1",
+ "socket2",
"tracing",
"windows-sys 0.60.2",
]
@@ -6192,12 +5992,12 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
-version = "0.4.3"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
+checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [
"num-traits",
- "rand 0.8.5",
+ "rand 0.10.1",
]
[[package]]
@@ -6209,12 +6009,6 @@ dependencies = [
"rand_core 0.9.3",
]
-[[package]]
-name = "range-alloc"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
-
[[package]]
name = "rangemap"
version = "1.7.1"
@@ -6245,14 +6039,14 @@ dependencies = [
"compact_str",
"hashbrown 0.16.1",
"indoc",
- "itertools 0.14.0",
+ "itertools",
"kasuari",
- "lru 0.16.3",
- "strum",
- "thiserror 2.0.17",
+ "lru",
+ "strum 0.27.2",
+ "thiserror 2.0.19",
"unicode-segmentation",
"unicode-truncate",
- "unicode-width 0.2.2",
+ "unicode-width",
]
[[package]]
@@ -6280,7 +6074,7 @@ dependencies = [
"ratatui",
"rustix 0.38.44",
"thiserror 1.0.69",
- "windows 0.58.0",
+ "windows",
]
[[package]]
@@ -6313,21 +6107,15 @@ dependencies = [
"hashbrown 0.16.1",
"indoc",
"instability",
- "itertools 0.14.0",
+ "itertools",
"line-clipping",
"ratatui-core",
- "strum",
+ "strum 0.27.2",
"time",
"unicode-segmentation",
- "unicode-width 0.2.2",
+ "unicode-width",
]
-[[package]]
-name = "raw-window-handle"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
-
[[package]]
name = "rawpointer"
version = "0.2.1"
@@ -6350,38 +6138,19 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
dependencies = [
- "either",
- "itertools 0.14.0",
- "rayon",
-]
-
-[[package]]
-name = "rayon-core"
-version = "1.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
-dependencies = [
- "crossbeam-deque",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "read-fonts"
-version = "0.22.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f"
-dependencies = [
- "bytemuck",
- "font-types",
+ "either",
+ "itertools",
+ "rayon",
]
[[package]]
-name = "redox_syscall"
-version = "0.3.5"
+name = "rayon-core"
+version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
- "bitflags 1.3.2",
+ "crossbeam-deque",
+ "crossbeam-utils",
]
[[package]]
@@ -6433,6 +6202,23 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "referencing"
+version = "0.49.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7af3eb523cce0df0af3c30d624b829b2dabd233172b5bc2615fcd03ceae8f746"
+dependencies = [
+ "ahash",
+ "fluent-uri",
+ "getrandom 0.3.4",
+ "hashbrown 0.17.1",
+ "itoa",
+ "micromap",
+ "parking_lot",
+ "percent-encoding",
+ "serde_json",
+]
+
[[package]]
name = "regex"
version = "1.12.2"
@@ -6447,9 +6233,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.4.13"
+version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick",
"memchr",
@@ -6464,15 +6250,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
-version = "0.8.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
-
-[[package]]
-name = "renderdoc-sys"
-version = "1.1.0"
+version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
@@ -6486,12 +6266,12 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "h2 0.4.13",
+ "h2",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
- "hyper 1.8.1",
- "hyper-rustls 0.27.7",
+ "hyper",
+ "hyper-rustls",
"hyper-util",
"js-sys",
"log",
@@ -6499,15 +6279,15 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
- "rustls 0.23.37",
- "rustls-native-certs 0.8.3",
+ "rustls",
+ "rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -6533,20 +6313,20 @@ dependencies = [
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
- "hyper 1.8.1",
- "hyper-rustls 0.27.7",
+ "hyper",
+ "hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
- "rustls 0.23.37",
+ "rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"sync_wrapper",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -6594,13 +6374,12 @@ dependencies = [
[[package]]
name = "rfc6979"
-version = "0.3.1"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
- "crypto-bigint 0.4.9",
"hmac 0.12.1",
- "zeroize",
+ "subtle",
]
[[package]]
@@ -6642,10 +6421,18 @@ dependencies = [
]
[[package]]
-name = "rustc-hash"
-version = "1.1.0"
+name = "rusqlite"
+version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags 2.10.0",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
[[package]]
name = "rustc-hash"
@@ -6702,32 +6489,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "rustls"
-version = "0.21.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
-dependencies = [
- "log",
- "ring",
- "rustls-webpki 0.101.7",
- "sct",
-]
-
-[[package]]
-name = "rustls"
-version = "0.22.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
-dependencies = [
- "log",
- "ring",
- "rustls-pki-types",
- "rustls-webpki 0.102.8",
- "subtle",
- "zeroize",
-]
-
[[package]]
name = "rustls"
version = "0.23.37"
@@ -6739,56 +6500,21 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
- "rustls-webpki 0.103.13",
+ "rustls-webpki",
"subtle",
"zeroize",
]
-[[package]]
-name = "rustls-connector"
-version = "0.19.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5bd40675c79c896f46d0031bf64c448b35e583dd2bc949751ddd800351e453a"
-dependencies = [
- "log",
- "rustls 0.22.4",
- "rustls-native-certs 0.7.3",
- "rustls-pki-types",
- "rustls-webpki 0.102.8",
-]
-
-[[package]]
-name = "rustls-native-certs"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
-dependencies = [
- "openssl-probe 0.1.6",
- "rustls-pemfile",
- "rustls-pki-types",
- "schannel",
- "security-framework 2.11.1",
-]
-
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
- "openssl-probe 0.2.1",
+ "openssl-probe",
"rustls-pki-types",
"schannel",
- "security-framework 3.6.0",
-]
-
-[[package]]
-name = "rustls-pemfile"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
-dependencies = [
- "rustls-pki-types",
+ "security-framework",
]
[[package]]
@@ -6797,7 +6523,7 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
dependencies = [
- "web-time 1.1.0",
+ "web-time",
"zeroize",
]
@@ -6809,14 +6535,14 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
- "jni 0.22.4",
+ "jni",
"log",
"once_cell",
- "rustls 0.23.37",
- "rustls-native-certs 0.8.3",
+ "rustls",
+ "rustls-native-certs",
"rustls-platform-verifier-android",
- "rustls-webpki 0.103.13",
- "security-framework 3.6.0",
+ "rustls-webpki",
+ "security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -6828,27 +6554,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
-[[package]]
-name = "rustls-webpki"
-version = "0.101.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
-dependencies = [
- "ring",
- "untrusted",
-]
-
-[[package]]
-name = "rustls-webpki"
-version = "0.102.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
-dependencies = [
- "ring",
- "rustls-pki-types",
- "untrusted",
-]
-
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -6867,23 +6572,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
-[[package]]
-name = "rustybuzz"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ee8fe2a8461a0854a37101fe7a1b13998d0cfa987e43248e81d2a5f4570f6fa"
-dependencies = [
- "bitflags 1.3.2",
- "bytemuck",
- "libm",
- "smallvec",
- "ttf-parser 0.20.0",
- "unicode-bidi-mirroring 0.1.0",
- "unicode-ccc 0.1.2",
- "unicode-properties",
- "unicode-script",
-]
-
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -6895,9 +6583,9 @@ dependencies = [
"core_maths",
"log",
"smallvec",
- "ttf-parser 0.25.1",
- "unicode-bidi-mirroring 0.4.0",
- "unicode-ccc 0.4.0",
+ "ttf-parser",
+ "unicode-bidi-mirroring",
+ "unicode-ccc",
"unicode-properties",
"unicode-script",
]
@@ -6917,6 +6605,19 @@ dependencies = [
"bytemuck",
]
+[[package]]
+name = "safetensors"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846"
+dependencies = [
+ "hashbrown 0.16.1",
+ "libc",
+ "serde",
+ "serde_json",
+ "tempfile",
+]
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -6945,45 +6646,41 @@ dependencies = [
]
[[package]]
-name = "scoped-tls"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
-
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
+name = "schemars"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+]
[[package]]
-name = "sct"
-version = "0.7.1"
+name = "schemars_derive"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
+checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
dependencies = [
- "ring",
- "untrusted",
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 3.0.3",
]
[[package]]
-name = "sctk-adwaita"
-version = "0.8.3"
+name = "scopeguard"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7"
-dependencies = [
- "ab_glyph",
- "log",
- "memmap2 0.9.9",
- "smithay-client-toolkit",
- "tiny-skia 0.11.4",
-]
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sec1"
-version = "0.3.0"
+version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
@@ -6993,19 +6690,6 @@ dependencies = [
"zeroize",
]
-[[package]]
-name = "security-framework"
-version = "2.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
-dependencies = [
- "bitflags 2.10.0",
- "core-foundation 0.9.4",
- "core-foundation-sys",
- "libc",
- "security-framework-sys",
-]
-
[[package]]
name = "security-framework"
version = "3.6.0"
@@ -7029,12 +6713,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "self_cell"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
-
[[package]]
name = "semver"
version = "1.0.27"
@@ -7071,6 +6749,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "serde_derive_internals"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -7196,9 +6885,9 @@ dependencies = [
[[package]]
name = "signature"
-version = "1.6.4"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core 0.6.4",
@@ -7247,16 +6936,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
-[[package]]
-name = "skrifa"
-version = "0.22.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe"
-dependencies = [
- "bytemuck",
- "read-fonts",
-]
-
[[package]]
name = "slab"
version = "0.4.11"
@@ -7278,50 +6957,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
-[[package]]
-name = "smithay-client-toolkit"
-version = "0.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a"
-dependencies = [
- "bitflags 2.10.0",
- "calloop",
- "calloop-wayland-source",
- "cursor-icon",
- "libc",
- "log",
- "memmap2 0.9.9",
- "rustix 0.38.44",
- "thiserror 1.0.69",
- "wayland-backend",
- "wayland-client",
- "wayland-csd-frame",
- "wayland-cursor",
- "wayland-protocols 0.31.2",
- "wayland-protocols-wlr 0.2.0",
- "wayland-scanner",
- "xkeysym",
-]
-
-[[package]]
-name = "smol_str"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "socket2"
-version = "0.5.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
-dependencies = [
- "libc",
- "windows-sys 0.52.0",
-]
-
[[package]]
name = "socket2"
version = "0.6.1"
@@ -7332,20 +6967,11 @@ dependencies = [
"windows-sys 0.60.2",
]
-[[package]]
-name = "spirv"
-version = "0.3.0+sdk-1.3.268.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
-dependencies = [
- "bitflags 2.10.0",
-]
-
[[package]]
name = "spki"
-version = "0.6.0"
+version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
@@ -7369,19 +6995,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
-[[package]]
-name = "stacker"
-version = "0.1.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
-dependencies = [
- "cc",
- "cfg-if",
- "libc",
- "psm",
- "windows-sys 0.59.0",
-]
-
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -7405,15 +7018,25 @@ dependencies = [
[[package]]
name = "string-interner"
-version = "0.15.0"
+version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07f9fdfdd31a0ff38b59deb401be81b73913d76c9cc5b1aed4e1330a223420b9"
+checksum = "ad3df9b59e2eded8d825c7c4363ad339a20fb6bc0b9a4778560f518f59910b15"
dependencies = [
- "cfg-if",
- "hashbrown 0.14.5",
+ "hashbrown 0.16.1",
"serde",
]
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -7426,7 +7049,16 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
- "strum_macros",
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros 0.28.0",
]
[[package]]
@@ -7442,16 +7074,22 @@ dependencies = [
]
[[package]]
-name = "subtle"
-version = "2.6.1"
+name = "strum_macros"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
[[package]]
-name = "svg_fmt"
-version = "0.4.5"
+name = "subtle"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
@@ -7464,21 +7102,21 @@ dependencies = [
]
[[package]]
-name = "swash"
-version = "0.1.19"
+name = "syn"
+version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
- "skrifa",
- "yazi",
- "zeno",
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
]
[[package]]
name = "syn"
-version = "1.0.109"
+version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -7487,9 +7125,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "2.0.117"
+version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
@@ -7530,19 +7168,10 @@ dependencies = [
"regex-syntax",
"serde",
"serde_derive",
- "thiserror 2.0.17",
+ "thiserror 2.0.19",
"walkdir",
]
-[[package]]
-name = "sys-locale"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "system-configuration"
version = "0.6.1"
@@ -7594,15 +7223,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "termcolor"
-version = "1.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
-dependencies = [
- "winapi-util",
-]
-
[[package]]
name = "terminal-colorsaurus"
version = "1.0.3"
@@ -7659,10 +7279,10 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"bitflags 2.10.0",
- "fancy-regex",
+ "fancy-regex 0.11.0",
"filedescriptor",
"finl_unicode",
- "fixedbitset 0.4.2",
+ "fixedbitset",
"hex",
"lazy_static",
"libc",
@@ -7703,11 +7323,11 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.17"
+version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
- "thiserror-impl 2.0.17",
+ "thiserror-impl 2.0.19",
]
[[package]]
@@ -7723,13 +7343,13 @@ dependencies = [
[[package]]
name = "thiserror-impl"
-version = "2.0.17"
+version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.117",
+ "syn 3.0.3",
]
[[package]]
@@ -7836,7 +7456,7 @@ dependencies = [
"bytemuck",
"cfg-if",
"log",
- "png 0.18.0",
+ "png 0.18.1",
"tiny-skia-path 0.12.0",
]
@@ -7900,7 +7520,7 @@ dependencies = [
"derive_builder",
"esaxx-rs",
"getrandom 0.3.4",
- "itertools 0.14.0",
+ "itertools",
"log",
"macro_rules_attribute",
"monostate",
@@ -7914,7 +7534,7 @@ dependencies = [
"serde",
"serde_json",
"spm_precompiled",
- "thiserror 2.0.17",
+ "thiserror 2.0.19",
"unicode-normalization-alignments",
"unicode-segmentation",
"unicode_categories",
@@ -7931,7 +7551,7 @@ dependencies = [
"mio",
"pin-project-lite",
"signal-hook-registry",
- "socket2 0.6.1",
+ "socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -7947,23 +7567,13 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "tokio-rustls"
-version = "0.24.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
-dependencies = [
- "rustls 0.21.12",
- "tokio",
-]
-
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
- "rustls 0.23.37",
+ "rustls",
"tokio",
]
@@ -7986,11 +7596,11 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
- "rustls 0.23.37",
- "rustls-native-certs 0.8.3",
+ "rustls",
+ "rustls-native-certs",
"rustls-pki-types",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"tungstenite",
]
@@ -8002,6 +7612,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
+ "futures-io",
"futures-sink",
"futures-util",
"pin-project-lite",
@@ -8016,8 +7627,8 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
dependencies = [
"serde",
"serde_spanned",
- "toml_datetime 0.6.3",
- "toml_edit 0.20.2",
+ "toml_datetime",
+ "toml_edit",
]
[[package]]
@@ -8029,15 +7640,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "toml_datetime"
-version = "1.1.1+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
-dependencies = [
- "serde_core",
-]
-
[[package]]
name = "toml_edit"
version = "0.20.2"
@@ -8047,29 +7649,8 @@ dependencies = [
"indexmap",
"serde",
"serde_spanned",
- "toml_datetime 0.6.3",
- "winnow 0.5.40",
-]
-
-[[package]]
-name = "toml_edit"
-version = "0.25.11+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
-dependencies = [
- "indexmap",
- "toml_datetime 1.1.1+spec-1.1.0",
- "toml_parser",
- "winnow 1.0.2",
-]
-
-[[package]]
-name = "toml_parser"
-version = "1.1.2+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
-dependencies = [
- "winnow 1.0.2",
+ "toml_datetime",
+ "winnow",
]
[[package]]
@@ -8155,16 +7736,19 @@ dependencies = [
[[package]]
name = "tract-core"
-version = "0.21.10"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7b5347639690871b124593a8c8903f1f369531498b8abaebd18eb5c58163971"
+checksum = "608e176a669d5da02cccc92bbfe5ee4e57686ed8841022608a9eba014d3b7886"
dependencies = [
"anyhow",
"anymap3",
- "bit-set",
+ "bit-set 0.10.0",
"derive-new",
"downcast-rs",
"dyn-clone",
+ "dyn-eq",
+ "erased-serde",
+ "inventory",
"lazy_static",
"log",
"maplit",
@@ -8172,8 +7756,9 @@ dependencies = [
"num-complex",
"num-integer",
"num-traits",
- "paste",
+ "pastey",
"rustfft",
+ "serde",
"smallvec",
"tract-data",
"tract-linalg",
@@ -8181,20 +7766,24 @@ dependencies = [
[[package]]
name = "tract-data"
-version = "0.21.10"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0a3f476a1804e05708e9bc5e2d29dcab82bad531e357d3d14d7da80fbba0b6d"
+checksum = "870236dd45aaeb1381023cb709a67ff14ece608ee0b37f99aa166d166db9b0d0"
dependencies = [
"anyhow",
"downcast-rs",
"dyn-clone",
+ "dyn-eq",
"dyn-hash",
"half",
- "itertools 0.12.1",
+ "inventory",
+ "itertools",
"lazy_static",
+ "libm",
"maplit",
"ndarray",
- "nom 7.1.3",
+ "nom 8.0.0",
+ "nom-language",
"num-integer",
"num-traits",
"parking_lot",
@@ -8203,11 +7792,21 @@ dependencies = [
"string-interner",
]
+[[package]]
+name = "tract-extra"
+version = "0.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "effcae1ebfce133e8bf6c79ad31298cbee0a9eceb3e4642fe1d14cb54cca78e6"
+dependencies = [
+ "tract-nnef",
+ "tract-pulse",
+]
+
[[package]]
name = "tract-hir"
-version = "0.21.10"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dca047ba1151fe3446fb0194d4b6ddb9ae8f361337c47a267870c53605fbafb"
+checksum = "28783b2bb583177685f65016a866b01eb3d383bce4fa7ae1b9692b325b64449d"
dependencies = [
"derive-new",
"log",
@@ -8216,43 +7815,45 @@ dependencies = [
[[package]]
name = "tract-linalg"
-version = "0.21.10"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb8e0703eb53ef1bbf77050ff261675818dd5f0d6c27044c6e48ede9b845f9e0"
+checksum = "d3e01491f7360806ef061af4c016a2d0a801d586768896394a0b8a7d6872c2b0"
dependencies = [
"byteorder",
"cc",
"derive-new",
"downcast-rs",
"dyn-clone",
+ "dyn-eq",
"dyn-hash",
"half",
"lazy_static",
- "liquid",
- "liquid-core",
- "liquid-derive",
"log",
+ "minijinja",
"num-traits",
- "paste",
- "rayon",
+ "pastey",
"scan_fmt",
- "smallvec",
- "time",
"tract-data",
- "unicode-normalization",
"walkdir",
]
[[package]]
name = "tract-nnef"
-version = "0.21.10"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72cb88a4367ec2c695610223cf886f01fc1deb5c9a82c7a74b1a5d32dc0b1466"
+checksum = "00417fabf01aeea7bc56107367862e5bb8da18c9ad850c9061d3823700479ecc"
dependencies = [
"byteorder",
+ "erased-serde",
"flate2",
"log",
- "nom 7.1.3",
+ "minijinja",
+ "nom 8.0.0",
+ "nom-language",
+ "safetensors",
+ "serde",
+ "serde_json",
+ "simd-adler32",
"tar",
"tract-core",
"walkdir",
@@ -8260,74 +7861,95 @@ dependencies = [
[[package]]
name = "tract-onnx"
-version = "0.21.10"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5830aa672b2aa4dc98a97a36e5988eaf77b3ecee65e2601619588d2ca557008"
+checksum = "a3215dd27bddd2a041a20fee750013b400135186d3485501c9274c755b19ceb0"
dependencies = [
"bytes",
"derive-new",
+ "dyn-eq",
"log",
- "memmap2 0.9.9",
+ "memmap2",
"num-integer",
"prost",
"smallvec",
+ "tract-extra",
"tract-hir",
"tract-nnef",
"tract-onnx-opl",
+ "tract-transformers",
]
[[package]]
name = "tract-onnx-opl"
-version = "0.21.10"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "121d3d224c806ba3d941f4bb50943ad33b59d1da5ae704d0e4e76d2808221f96"
+checksum = "44f549ad3f245c1c00ce710c66cc0e086be0b637f35934d37b655ff175e1ab3e"
dependencies = [
- "getrandom 0.2.16",
+ "dyn-eq",
+ "getrandom 0.4.1",
"log",
- "rand 0.8.5",
+ "rand 0.10.1",
"rand_distr",
"rustfft",
+ "tract-extra",
"tract-nnef",
]
[[package]]
-name = "transpose"
-version = "0.2.3"
+name = "tract-pulse"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
+checksum = "a164e22e96ab9b5c90458fa270700570d87963e3dec9ccaac1ab18d9fa763ce2"
dependencies = [
- "num-integer",
- "strength_reduce",
+ "downcast-rs",
+ "dyn-eq",
+ "erased-serde",
+ "lazy_static",
+ "log",
+ "serde",
+ "tract-pulse-opl",
+ "tract-transformers",
]
[[package]]
-name = "tree_magic_mini"
-version = "3.2.2"
+name = "tract-pulse-opl"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
+checksum = "65418f9e93e0af0d567f4f2f1bf2309b53930fa2543c635a69fda10c87a04987"
dependencies = [
- "memchr",
- "nom 8.0.0",
- "petgraph",
+ "downcast-rs",
+ "dyn-eq",
+ "lazy_static",
+ "tract-nnef",
]
[[package]]
-name = "try-lock"
-version = "0.2.5"
+name = "tract-transformers"
+version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+checksum = "8471ebf7f52d226552283d9130539595540c28ed41b8e032df3830507d3ccdd5"
+dependencies = [
+ "float-ord",
+ "rayon",
+ "tract-nnef",
+]
[[package]]
-name = "ttf-parser"
-version = "0.19.2"
+name = "transpose"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1"
+checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
+dependencies = [
+ "num-integer",
+ "strength_reduce",
+]
[[package]]
-name = "ttf-parser"
-version = "0.20.0"
+name = "try-lock"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
@@ -8351,7 +7973,7 @@ dependencies = [
"httparse",
"log",
"rand 0.8.5",
- "rustls 0.23.37",
+ "rustls",
"rustls-pki-types",
"sha1",
"thiserror 1.0.69",
@@ -8360,13 +7982,19 @@ dependencies = [
[[package]]
name = "type1-encoding-parser"
-version = "0.1.0"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b"
+checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749"
dependencies = [
"pom",
]
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
[[package]]
name = "typenum"
version = "1.20.0"
@@ -8442,12 +8070,6 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
-[[package]]
-name = "unicode-bidi-mirroring"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56d12260fb92d52f9008be7e4bca09f584780eb2266dc8fecc6a192bec561694"
-
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
@@ -8456,15 +8078,15 @@ checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
[[package]]
name = "unicode-ccc"
-version = "0.1.2"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc2520efa644f8268dce4dcd3050eaa7fc044fca03961e9998ac7e2e92b77cf1"
+checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]]
-name = "unicode-ccc"
-version = "0.4.0"
+name = "unicode-general-category"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
@@ -8472,12 +8094,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
-[[package]]
-name = "unicode-linebreak"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
-
[[package]]
name = "unicode-normalization"
version = "0.1.25"
@@ -8520,9 +8136,9 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
- "itertools 0.14.0",
+ "itertools",
"unicode-segmentation",
- "unicode-width 0.2.2",
+ "unicode-width",
]
[[package]]
@@ -8531,12 +8147,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
-[[package]]
-name = "unicode-width"
-version = "0.1.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
-
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -8594,13 +8204,13 @@ dependencies = [
"base64 0.22.1",
"data-url",
"flate2",
- "fontdb 0.23.0",
+ "fontdb",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree 0.21.1",
- "rustybuzz 0.20.1",
+ "rustybuzz",
"simplecss",
"siphasher",
"strict-num",
@@ -8621,19 +8231,19 @@ dependencies = [
"base64 0.22.1",
"data-url",
"flate2",
- "fontdb 0.23.0",
+ "fontdb",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree 0.21.1",
- "rustybuzz 0.20.1",
+ "rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path 0.12.0",
- "ttf-parser 0.25.1",
+ "ttf-parser",
"unicode-bidi",
"unicode-script",
"unicode-vo",
@@ -8671,6 +8281,22 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "uuid-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -8744,12 +8370,6 @@ dependencies = [
"wit-bindgen 0.51.0",
]
-[[package]]
-name = "wasite"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
-
[[package]]
name = "wasm-bindgen"
version = "0.2.122"
@@ -8802,201 +8422,67 @@ version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "wasm-encoder"
-version = "0.244.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
-dependencies = [
- "leb128fmt",
- "wasmparser",
-]
-
-[[package]]
-name = "wasm-metadata"
-version = "0.244.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
-dependencies = [
- "anyhow",
- "indexmap",
- "wasm-encoder",
- "wasmparser",
-]
-
-[[package]]
-name = "wasm-streams"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
-dependencies = [
- "futures-util",
- "js-sys",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
-[[package]]
-name = "wasm-streams"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
-dependencies = [
- "futures-util",
- "js-sys",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
-[[package]]
-name = "wasmparser"
-version = "0.244.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
-dependencies = [
- "bitflags 2.10.0",
- "hashbrown 0.15.5",
- "indexmap",
- "semver",
-]
-
-[[package]]
-name = "wayland-backend"
-version = "0.3.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
-dependencies = [
- "cc",
- "downcast-rs",
- "rustix 1.1.3",
- "scoped-tls",
- "smallvec",
- "wayland-sys",
-]
-
-[[package]]
-name = "wayland-client"
-version = "0.31.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
-dependencies = [
- "bitflags 2.10.0",
- "rustix 1.1.3",
- "wayland-backend",
- "wayland-scanner",
-]
-
-[[package]]
-name = "wayland-csd-frame"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
-dependencies = [
- "bitflags 2.10.0",
- "cursor-icon",
- "wayland-backend",
-]
-
-[[package]]
-name = "wayland-cursor"
-version = "0.31.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
-dependencies = [
- "rustix 1.1.3",
- "wayland-client",
- "xcursor",
-]
-
-[[package]]
-name = "wayland-protocols"
-version = "0.31.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4"
-dependencies = [
- "bitflags 2.10.0",
- "wayland-backend",
- "wayland-client",
- "wayland-scanner",
-]
-
-[[package]]
-name = "wayland-protocols"
-version = "0.32.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
-dependencies = [
- "bitflags 2.10.0",
- "wayland-backend",
- "wayland-client",
- "wayland-scanner",
+ "unicode-ident",
]
[[package]]
-name = "wayland-protocols-plasma"
-version = "0.2.0"
+name = "wasm-encoder"
+version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
- "bitflags 2.10.0",
- "wayland-backend",
- "wayland-client",
- "wayland-protocols 0.31.2",
- "wayland-scanner",
+ "leb128fmt",
+ "wasmparser",
]
[[package]]
-name = "wayland-protocols-wlr"
-version = "0.2.0"
+name = "wasm-metadata"
+version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
- "bitflags 2.10.0",
- "wayland-backend",
- "wayland-client",
- "wayland-protocols 0.31.2",
- "wayland-scanner",
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
]
[[package]]
-name = "wayland-protocols-wlr"
-version = "0.3.12"
+name = "wasm-streams"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
- "bitflags 2.10.0",
- "wayland-backend",
- "wayland-client",
- "wayland-protocols 0.32.12",
- "wayland-scanner",
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
]
[[package]]
-name = "wayland-scanner"
-version = "0.31.10"
+name = "wasm-streams"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
- "proc-macro2",
- "quick-xml",
- "quote",
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
]
[[package]]
-name = "wayland-sys"
-version = "0.31.11"
+name = "wasmparser"
+version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
- "dlib",
- "log",
- "once_cell",
- "pkg-config",
+ "bitflags 2.10.0",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
]
[[package]]
@@ -9009,16 +8495,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "web-time"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
[[package]]
name = "web-time"
version = "1.1.0"
@@ -9119,130 +8595,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
dependencies = [
"bitflags 1.3.2",
- "euclid 0.22.13",
+ "euclid 0.22.14",
"lazy_static",
"serde",
"wezterm-dynamic",
]
-[[package]]
-name = "wgpu"
-version = "0.19.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01"
-dependencies = [
- "arrayvec",
- "cfg-if",
- "cfg_aliases 0.1.1",
- "js-sys",
- "log",
- "naga",
- "parking_lot",
- "profiling",
- "raw-window-handle",
- "smallvec",
- "static_assertions",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
- "wgpu-core",
- "wgpu-hal",
- "wgpu-types",
-]
-
-[[package]]
-name = "wgpu-core"
-version = "0.19.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a"
-dependencies = [
- "arrayvec",
- "bit-vec",
- "bitflags 2.10.0",
- "cfg_aliases 0.1.1",
- "codespan-reporting",
- "indexmap",
- "log",
- "naga",
- "once_cell",
- "parking_lot",
- "profiling",
- "raw-window-handle",
- "rustc-hash 1.1.0",
- "smallvec",
- "thiserror 1.0.69",
- "web-sys",
- "wgpu-hal",
- "wgpu-types",
-]
-
-[[package]]
-name = "wgpu-hal"
-version = "0.19.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703"
-dependencies = [
- "android_system_properties",
- "arrayvec",
- "ash",
- "bit-set",
- "bitflags 2.10.0",
- "block",
- "cfg_aliases 0.1.1",
- "core-graphics-types",
- "d3d12",
- "glow",
- "glutin_wgl_sys",
- "gpu-alloc",
- "gpu-allocator",
- "gpu-descriptor",
- "hassle-rs",
- "js-sys",
- "khronos-egl",
- "libc",
- "libloading 0.8.9",
- "log",
- "metal",
- "naga",
- "ndk-sys",
- "objc",
- "once_cell",
- "parking_lot",
- "profiling",
- "range-alloc",
- "raw-window-handle",
- "renderdoc-sys",
- "rustc-hash 1.1.0",
- "smallvec",
- "thiserror 1.0.69",
- "wasm-bindgen",
- "web-sys",
- "wgpu-types",
- "winapi",
-]
-
-[[package]]
-name = "wgpu-types"
-version = "0.19.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805"
-dependencies = [
- "bitflags 2.10.0",
- "js-sys",
- "web-sys",
-]
-
-[[package]]
-name = "whoami"
-version = "1.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
-dependencies = [
- "libredox",
- "wasite",
- "web-sys",
-]
-
[[package]]
name = "wide"
version = "0.8.3"
@@ -9253,12 +8611,6 @@ dependencies = [
"safe_arch",
]
-[[package]]
-name = "widestring"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
-
[[package]]
name = "winapi"
version = "0.3.9"
@@ -9290,16 +8642,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-[[package]]
-name = "windows"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
-dependencies = [
- "windows-core 0.52.0",
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows"
version = "0.58.0"
@@ -9310,15 +8652,6 @@ dependencies = [
"windows-targets 0.52.6",
]
-[[package]]
-name = "windows-core"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows-core"
version = "0.58.0"
@@ -9443,15 +8776,6 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "windows-sys"
-version = "0.45.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
-dependencies = [
- "windows-targets 0.42.2",
-]
-
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -9497,21 +8821,6 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "windows-targets"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
-dependencies = [
- "windows_aarch64_gnullvm 0.42.2",
- "windows_aarch64_msvc 0.42.2",
- "windows_i686_gnu 0.42.2",
- "windows_i686_msvc 0.42.2",
- "windows_x86_64_gnu 0.42.2",
- "windows_x86_64_gnullvm 0.42.2",
- "windows_x86_64_msvc 0.42.2",
-]
-
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -9560,12 +8869,6 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
-
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -9584,12 +8887,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -9608,12 +8905,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
-
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -9644,12 +8935,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
-
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -9668,12 +8953,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -9692,12 +8971,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -9716,12 +8989,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -9740,54 +9007,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-[[package]]
-name = "winit"
-version = "0.29.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca"
-dependencies = [
- "ahash",
- "android-activity",
- "atomic-waker",
- "bitflags 2.10.0",
- "bytemuck",
- "calloop",
- "cfg_aliases 0.1.1",
- "core-foundation 0.9.4",
- "core-graphics",
- "cursor-icon",
- "icrate",
- "js-sys",
- "libc",
- "log",
- "memmap2 0.9.9",
- "ndk",
- "ndk-sys",
- "objc2 0.4.1",
- "once_cell",
- "orbclient",
- "percent-encoding",
- "raw-window-handle",
- "redox_syscall 0.3.5",
- "rustix 0.38.44",
- "sctk-adwaita",
- "smithay-client-toolkit",
- "smol_str",
- "unicode-segmentation",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "wayland-backend",
- "wayland-client",
- "wayland-protocols 0.31.2",
- "wayland-protocols-plasma",
- "web-sys",
- "web-time 0.2.4",
- "windows-sys 0.48.0",
- "x11-dl",
- "x11rb",
- "xkbcommon-dl",
-]
-
[[package]]
name = "winnow"
version = "0.5.40"
@@ -9797,15 +9016,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "winnow"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "wit-bindgen"
version = "0.46.0"
@@ -9900,24 +9110,6 @@ dependencies = [
"wasmparser",
]
-[[package]]
-name = "wl-clipboard-rs"
-version = "0.9.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
-dependencies = [
- "libc",
- "log",
- "os_pipe",
- "rustix 1.1.3",
- "thiserror 2.0.17",
- "tree_magic_mini",
- "wayland-backend",
- "wayland-client",
- "wayland-protocols 0.32.12",
- "wayland-protocols-wlr 0.3.12",
-]
-
[[package]]
name = "writeable"
version = "0.6.2"
@@ -9933,28 +9125,13 @@ dependencies = [
"tap",
]
-[[package]]
-name = "x11-dl"
-version = "2.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
-dependencies = [
- "libc",
- "once_cell",
- "pkg-config",
-]
-
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
- "as-raw-xcb-connection",
"gethostname",
- "libc",
- "libloading 0.8.9",
- "once_cell",
"rustix 1.1.3",
"x11rb-protocol",
]
@@ -9975,37 +9152,12 @@ dependencies = [
"rustix 1.1.3",
]
-[[package]]
-name = "xcursor"
-version = "0.3.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
-
-[[package]]
-name = "xkbcommon-dl"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
-dependencies = [
- "bitflags 2.10.0",
- "dlib",
- "log",
- "once_cell",
- "xkeysym",
-]
-
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
-[[package]]
-name = "xml-rs"
-version = "0.8.28"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
-
[[package]]
name = "xmlparser"
version = "0.13.6"
@@ -10030,12 +9182,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
-[[package]]
-name = "yazi"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1"
-
[[package]]
name = "yoke"
version = "0.8.1"
@@ -10059,12 +9205,6 @@ dependencies = [
"synstructure",
]
-[[package]]
-name = "zeno"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697"
-
[[package]]
name = "zerocopy"
version = "0.8.33"
diff --git a/Cargo.toml b/Cargo.toml
index e482c94bf6..af5982279a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "jcode"
-version = "0.50.0"
+version = "0.84.0"
description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools"
edition = "2024"
autobins = false
@@ -25,8 +25,12 @@ members = [
"crates/jcode-build-support",
"crates/jcode-compaction-core",
"crates/jcode-config-types",
+ "crates/jcode-command-risk",
"crates/jcode-core",
"crates/jcode-fuzzy",
+ "crates/jcode-harness-api",
+ "crates/jcode-harness-api-server",
+ "crates/jcode-sdk",
"crates/jcode-memory-types",
"crates/jcode-message-types",
"crates/jcode-overnight-core",
@@ -44,6 +48,7 @@ members = [
"crates/jcode-provider-metadata",
"crates/jcode-provider-env",
"crates/jcode-provider-core",
+ "crates/jcode-schema-dialect",
"crates/jcode-provider-bedrock",
"crates/jcode-provider-anthropic",
"crates/jcode-provider-antigravity",
@@ -59,6 +64,7 @@ members = [
"crates/jcode-provider-openrouter-runtime",
"crates/jcode-provider-anthropic-runtime",
"crates/jcode-provider-openai-runtime",
+ "crates/jcode-provider-grok-build-runtime",
"crates/jcode-provider-doctor",
"crates/jcode-tui-markdown",
"crates/jcode-tui-messages",
@@ -66,6 +72,7 @@ members = [
"crates/jcode-tui-core",
"crates/jcode-tui-mermaid",
"crates/jcode-task-types",
+ "crates/jcode-transport",
"crates/jcode-tool-core",
"crates/jcode-tool-types",
"crates/jcode-tui-account-picker",
@@ -82,7 +89,6 @@ members = [
"crates/jcode-terminal-image",
"crates/jcode-telemetry-core",
"crates/jcode-tui-workspace",
- "crates/jcode-desktop",
"crates/jcode-render-core",
]
@@ -158,6 +164,7 @@ chrono = { version = "0.4", features = ["serde"] }
sha2 = "0.10"
hex = "0.4"
open = "5" # Open URLs in browser
+jcode-tui-style = { path = "crates/jcode-tui-style" }
jcode-tui-session-picker = { path = "crates/jcode-tui-session-picker", features = ["serde"] }
# Streaming
@@ -171,6 +178,7 @@ crossterm = { version = "0.29", features = ["event-stream"] }
# PDF parsing (behind feature flag)
jcode-build-meta = { path = "crates/jcode-build-meta" }
+# Tiny leaf helpers (console/env/fs/id) used directly by the cli layer.
# Presentation layer: the terminal UI (`tui`) + offline replay (`video_export`),
# compiled as a separate rustc unit. It re-exports the application core
# (`jcode-app-core`, which re-exports `jcode-base`), so the root crate (cli +
@@ -192,6 +200,7 @@ jcode-provider-claude-cli-runtime = { path = "crates/jcode-provider-claude-cli-r
jcode-provider-openrouter-runtime = { path = "crates/jcode-provider-openrouter-runtime" }
jcode-provider-anthropic-runtime = { path = "crates/jcode-provider-anthropic-runtime" }
jcode-provider-openai-runtime = { path = "crates/jcode-provider-openai-runtime" }
+jcode-provider-grok-build-runtime = { path = "crates/jcode-provider-grok-build-runtime" }
jcode-selfdev-types = { path = "crates/jcode-selfdev-types" }
# Archive extraction (for auto-update)
@@ -220,9 +229,20 @@ embeddings = ["jcode-tui/embeddings"]
# Live AWS Bedrock support (the AWS SDK stack) lives in jcode-provider-bedrock;
# forwards down through jcode-tui -> jcode-app-core -> jcode-base.
bedrock = ["jcode-tui/bedrock"]
+# Internal release feature: build a supported OpenSSL inside the CentOS 7
+# compatibility image while preserving the glibc 2.17 runtime baseline.
+linux-compat-vendored-openssl = ["jcode-tui/linux-compat-vendored-openssl"]
mmdr-size-api = ["jcode-tui/mmdr-size-api"]
pdf = ["jcode-tui/pdf"]
+# The harness API bridge ships inside the released binary as `jcode api-bridge`.
+# SDK users must not need a Rust toolchain to reach the API: requiring
+# `cargo run -p jcode-harness-api-server` made the TypeScript SDK unusable for
+# anyone who installed jcode from a release artifact. Unix-only because the
+# bridge listens on a Unix socket.
+[target.'cfg(unix)'.dependencies]
+jcode-harness-api-server = { path = "crates/jcode-harness-api-server" }
+
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
@@ -261,6 +281,7 @@ objc2-app-kit = { version = "0.3", features = [
"NSMenuItem",
"NSRunningApplication",
] }
+objc2-user-notifications = "0.3"
[profile.release]
opt-level = 1
@@ -302,6 +323,103 @@ opt-level = 3
[profile.test.package."jcode-tui-anim"]
opt-level = 3
+# The fuzzy matcher runs a char-level DP over every picker entry on every
+# keystroke (e.g. /model search across hundreds of routes). Like the anim
+# kernels above, it is small, pure, dependency-free code that rarely changes,
+# so keeping it fully optimized in dev/selfdev builds costs one compile and
+# removes visible input lag in unoptimized builds.
+[profile.dev.package."jcode-fuzzy"]
+opt-level = 3
+
+[profile.selfdev.package."jcode-fuzzy"]
+opt-level = 3
+
+[profile.test.package."jcode-fuzzy"]
+opt-level = 3
+
+[profile.release.package."jcode-fuzzy"]
+opt-level = 3
+
+# Keep the terminal render stack optimized even in dev/selfdev/test builds.
+#
+# Every TUI frame walks ratatui buffers cell-by-cell and re-measures visible
+# text with unicode-width/unicode-segmentation. At opt-level = 0 those inner
+# loops dominated live-client profiles (perf showed str_width, grapheme
+# bsearch, and Buffer::index_of_opt as the top symbols) and pushed a routine
+# full frame to ~12ms p50 / 21ms p95, which reads as input-line lag while
+# streaming saturates the redraw loop. These are stable third-party crates
+# that almost never recompile, so pinning them is a one-time compile cost
+# (same rationale as jcode-tui-anim above).
+[profile.dev.package.ratatui]
+opt-level = 3
+[profile.selfdev.package.ratatui]
+opt-level = 3
+[profile.test.package.ratatui]
+opt-level = 3
+
+[profile.dev.package.ratatui-core]
+opt-level = 3
+[profile.selfdev.package.ratatui-core]
+opt-level = 3
+[profile.test.package.ratatui-core]
+opt-level = 3
+
+[profile.dev.package.ratatui-widgets]
+opt-level = 3
+[profile.selfdev.package.ratatui-widgets]
+opt-level = 3
+[profile.test.package.ratatui-widgets]
+opt-level = 3
+
+[profile.dev.package.ratatui-crossterm]
+opt-level = 3
+[profile.selfdev.package.ratatui-crossterm]
+opt-level = 3
+[profile.test.package.ratatui-crossterm]
+opt-level = 3
+
+[profile.dev.package.crossterm]
+opt-level = 3
+[profile.selfdev.package.crossterm]
+opt-level = 3
+[profile.test.package.crossterm]
+opt-level = 3
+
+[profile.dev.package.unicode-width]
+opt-level = 3
+[profile.selfdev.package.unicode-width]
+opt-level = 3
+[profile.test.package.unicode-width]
+opt-level = 3
+
+[profile.dev.package.unicode-segmentation]
+opt-level = 3
+[profile.selfdev.package.unicode-segmentation]
+opt-level = 3
+[profile.test.package.unicode-segmentation]
+opt-level = 3
+
+[profile.dev.package.unicode-truncate]
+opt-level = 3
+[profile.selfdev.package.unicode-truncate]
+opt-level = 3
+[profile.test.package.unicode-truncate]
+opt-level = 3
+
+[profile.dev.package.unicode-linebreak]
+opt-level = 3
+[profile.selfdev.package.unicode-linebreak]
+opt-level = 3
+[profile.test.package.unicode-linebreak]
+opt-level = 3
+
+[profile.dev.package.compact_str]
+opt-level = 3
+[profile.selfdev.package.compact_str]
+opt-level = 3
+[profile.test.package.compact_str]
+opt-level = 3
+
# Keep the text-shaping stack optimized even in dev/selfdev/test builds.
#
# cosmic-text + rustybuzz + ttf-parser + swash + yazi do all of the desktop
@@ -481,6 +599,10 @@ codegen-units = 256
[dev-dependencies]
async-stream = "0.3"
+jcode-harness-api = { path = "crates/jcode-harness-api" }
+# Used by tests/context_window_matrix.rs to assert the shared context-window
+# resolution invariants directly, without going through a live provider.
+jcode-provider-core = { path = "crates/jcode-provider-core" }
# Enables the downstream test-support helpers (storage::lock_test_env,
# auth::test_sandbox, bus::reset_models_updated_publish_state_for_tests, the
# ExternalAuthReviewCandidate read accessors) for the root crate's own cli test
diff --git a/OAUTH.md b/OAUTH.md
index 3a31f03a06..e7275a157c 100644
--- a/OAUTH.md
+++ b/OAUTH.md
@@ -269,7 +269,7 @@ jcode --provider-profile my-api auth-test --no-tool-smoke
This writes `[providers.my-api]` in `~/.jcode/config.toml` and stores the key in jcode's private app config dir, for example `~/.config/jcode/provider-my-api.env`. For localhost servers, use `--no-api-key`.
-Two notable presets are:
+Notable presets include:
### Fireworks
- Login: `jcode login --provider fireworks`
@@ -279,6 +279,16 @@ Two notable presets are:
- Default model hint: `accounts/fireworks/routers/kimi-k2p5-turbo`
- Docs:
+### Novita AI
+- Login: `jcode login --provider novita` or `/login novita` in the TUI
+- Authentication: pay-as-you-go API key, not a subscription login or browser OAuth
+- Stored env file: `~/.config/jcode/novita.env`
+- API key env var: `NOVITA_API_KEY`
+- Base URL: `https://api.novita.ai/openai`
+- Default model hint: `zai-org/glm-5.3`
+- Get a key:
+- Docs:
+
### MiniMax
- Login: `jcode login --provider minimax`
- Stored env file: `~/.config/jcode/minimax.env`
diff --git a/README.md b/README.md
index fc24a36269..99abe8fa61 100644
--- a/README.md
+++ b/README.md
@@ -9,18 +9,18 @@
[](https://github.com/1jehuang/jcode/stargazers)
[](https://discord.gg/nBe9vGyK9a)
-The next generation coding agent harness to raise the skill ceiling.
-Built for multi-session workflows, infinite customizability, and performance.
+The most RAM efficient harness
+The most intelligent harness
-
+
-
-
+
+
-[Website](https://jcode.sh) · [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Further Reading](#further-reading) · [Contributing](CONTRIBUTING.md)
+[Website](https://jcode.sh) · [Docs](https://jcode.sh/docs) · [SDK](https://jcode.sh/sdk) · [Benchmarks](https://jcode.sh/bench) · [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Further Reading](#further-reading) · [Contributing](CONTRIBUTING.md)
@@ -45,6 +45,23 @@ irm https://jcode.sh/install.ps1 | iex
Need Homebrew, source builds, provider setup, or want an agent to set it up for you?
[Jump to detailed installation](#detailed-installation).
+### Updating
+
+Run `/update` in the TUI to download the latest stable release in the background
+and reload with your session preserved. From a terminal, use `jcode update`, then
+restart the client. Both commands use the same update policy, including for dev builds.
+
+Older or equal release versions are skipped. For a development build, Jcode also
+compares the running binary's Git commit with the release tag. Builds ahead of,
+identical to, or diverged from the release are preserved. If ancestry cannot be
+verified locally or through GitHub, the update stops rather than risking a downgrade.
+The displayed dev patch includes a commit-count offset, so it is not used as a
+release version comparison.
+
+This is the default `features.update_channel = "stable"` behavior. An explicit
+`"main"` channel still opts into source-branch updates. Use `/rebuild` or the
+self-dev build workflow to rebuild your own checkout.
+
---
@@ -300,10 +317,12 @@ To show you important information without taking space away from the screen that
Jcode can render at over a thousand fps. Your monitor will not have the refresh rate to show you, but this means you will not have silly flicker problems.
-The custom scrollback implementation of jcode allows it to do much more than a native scrollback. However, it is a terminal-level limitation that I cannot have smooth, partial line scrolling with a custom scrollback. To fix this, I made my own terminal. Handterm https://github.com/1jehuang/handterm implements a native scroll api, and also happens to be very effiecent. This is a work in progress. Scrolling is still well implemented for normal terminals.
+The custom scrollback implementation of jcode allows it to do much more than a native scrollback. However, it is a terminal-level limitation that I cannot have smooth, partial line scrolling with a custom scrollback. To fix this, I made my own terminal. Handterm https://github.com/1jehuang/handterm implements a native scroll api, and also happens to be very efficient. This is a work in progress. Scrolling is still well implemented for normal terminals.
Jcode is left-aligned by default. You can switch to centered mode with the `Alt+C` hotkey, with the `/alignment` command, or in the config.
+To disable emoji globally in TUI and CLI output, set `emoji = false` under `[display]` in `~/.jcode/config.toml`, or launch with `JCODE_NO_EMOJI=1`. Jcode replaces emoji with compact ASCII markers while preserving other Unicode text.
+
---
## Swarm
@@ -337,13 +356,19 @@ jcode works with subscription-backed OAuth flows and many provider integrations,
- **Azure OpenAI** (`jcode login --provider azure`)
- **Alibaba Cloud Coding Plan** (`jcode login --provider alibaba-coding-plan`)
- **Fireworks** (`jcode login --provider fireworks`)
+- **Novita AI** (`jcode login --provider novita`, API key)
- **MiniMax** (`jcode login --provider minimax`)
+- **Meta Model API / Muse** (`jcode login --provider meta-muse`)
- **LM Studio** (`jcode login --provider lmstudio`)
- **Ollama** (`jcode login --provider ollama`)
- **Custom OpenAI-compatible endpoint** (`jcode login --provider openai-compatible`)
For custom OpenAI-compatible endpoints, jcode now prompts for the API base and supports local localhost servers without requiring an API key.
+The native OpenAI providers use Responses WebSocket v2 with opportunistic
+background prewarming and HTTPS fallback. See [OpenAI WebSocket transport](docs/OPENAI_WEBSOCKET.md)
+for behavior, controls, and verification.
+
### Config-file setup for self-hosted endpoints and MCP
If you prefer to configure things by editing files instead of using the login UI, jcode supports both a custom OpenAI-compatible endpoint config and MCP config files.
@@ -360,18 +385,20 @@ There are two ways to set one up:
jcode login --provider
# for example:
jcode login --provider openrouter
+ jcode login --provider orcarouter
jcode login --provider deepseek
jcode login --provider opencode # OpenCode Zen
jcode login --provider moonshotai
+ jcode login --provider meta-muse # Meta Model API / Muse Spark
```
- Built-in OpenAI-compatible profile ids include: `openrouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list.
+ Built-in OpenAI-compatible profile ids include: `openrouter`, `orcarouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `meta-muse` (Meta Model API / Muse Spark), `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list.
- **Any other endpoint** — point jcode at an arbitrary OpenAI-compatible API (hosted or local) with `jcode login --provider openai-compatible` or the scriptable `jcode provider add` command described below.
Useful environment overrides for these endpoints:
-- `JCODE_STREAM_IDLE_TIMEOUT_SECS` — raise the streaming idle timeout (default 180s) for slow reasoning models that think silently before emitting tokens. Also settable as `[provider] stream_idle_timeout_secs` in `config.toml`.
+- `JCODE_STREAM_IDLE_TIMEOUT_SECS` — raise the base streaming idle timeout (default 180s) for slow reasoning models that think silently before emitting tokens. High reasoning efforts scale this automatically (high 2x, xhigh 3x, max 4x). Also settable as `[provider] stream_idle_timeout_secs` in `config.toml`.
- Per-model `context_window` (alias `context_limit`) in a `[[providers..models]]` entry — set the context window when the endpoint has no usable `/v1/models` response, so jcode does not fall back to the generic 200k default.
- `extra_body` — inject non-standard top-level fields into every chat/completions request body for backends that require them. See [Extra request-body fields](#extra-request-body-fields-extra_body) below.
@@ -444,12 +471,47 @@ base_url = "https://llm.example.com/v1"
api_key_env = "JCODE_PROVIDER_MY_API_API_KEY"
env_file = "provider-my-api.env"
default_model = "my-model-id"
+# Optional: prevent model names such as `gpt-5-*` from automatically enabling
+# `reasoning_effort` on gateways that reject it.
+disable_reasoning_heuristics = true
[[providers.my-api.models]]
id = "my-model-id"
context_window = 128000
+# Explicitly enable `/effort` and select this model's initial effort. Set
+# `reasoning = false` on an individual model to disable it instead.
+reasoning = true
+reasoning_effort = "high"
```
+Anthropic Messages-compatible gateways use the same named-profile surface with
+`type = "anthropic-compatible"`. The profile can select bearer, custom-header,
+or no authentication and attach gateway-specific headers to every request:
+
+```toml
+[provider]
+default_provider = "corp-claude"
+default_model = "claude-sonnet-4-6"
+
+[providers.corp-claude]
+type = "anthropic-compatible"
+base_url = "https://gateway.example.com/anthropic/v1"
+auth = "bearer"
+api_key_env = "CORP_CLAUDE_TOKEN"
+default_model = "claude-sonnet-4-6"
+
+[providers.corp-claude.headers]
+x-tenant-id = "tenant-42"
+
+[[providers.corp-claude.models]]
+id = "claude-sonnet-4-6"
+context_window = 200000
+```
+
+For direct environment-based configuration, `ANTHROPIC_BASE_URL` overrides the
+non-OAuth Messages endpoint and `ANTHROPIC_AUTH_TOKEN` is sent as a bearer token.
+Claude OAuth traffic always continues to use Anthropic's official endpoints.
+
##### Extra request-body fields (`extra_body`)
Some OpenAI-compatible backends require non-standard top-level request fields. For example, NVIDIA NIM DeepSeek-V4 reasoning models (`deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro`) only enable thinking when the request includes `chat_template_kwargs`; without it they reply without reasoning (or, for some deployments, hang). jcode lets you inject arbitrary top-level fields two ways.
@@ -512,6 +574,15 @@ Claude Code compatibility:
- `.mcp.json` at the repo root (Claude Code's project config)
- `.claude/mcp.json` (legacy fallback)
+Claude Code config is read live on every load rather than copied into jcode's
+global config. Additions, edits, and deletions therefore take effect without
+leaving a stale snapshot (and inline environment values are not duplicated).
+For migration from Codex CLI, jcode still performs a one-time import from
+`~/.codex/config.toml` into `~/.jcode/mcp.json` when the latter does not exist.
+That imported file is then jcode-owned; later Codex changes are not synced
+automatically. Imported environment values are copied too and may contain
+secrets.
+
Both the canonical `mcpServers` key and jcode's historical `servers` key are accepted. jcode currently supports stdio (command-based) servers only; HTTP/SSE entries (`"type": "http"`/`"sse"`) are recognized and skipped with a log line.
Example MCP config:
@@ -524,12 +595,16 @@ Example MCP config:
"args": ["--root", "/workspace"],
"env": {},
"shared": true
+ },
+ "websearch": {
+ "command": "/path/to/slow-mcp-server",
+ "timeout_secs": 120
}
}
}
```
-On first run, jcode also tries to import MCP servers from `~/.claude.json` (falling back to the legacy `~/.claude/mcp.json`) and `~/.codex/config.toml` if `~/.jcode/mcp.json` does not exist yet.
+Each request to an MCP server (`tools/call`, `tools/list`, `initialize`) times out after 30 seconds by default. Set `timeout_secs` on a server whose tools legitimately run longer.
For headless or SSH sessions, OAuth-style providers support `jcode login --provider --no-browser` (alias: `--headless`) so jcode prints the auth URL/QR and falls back to manual code or callback paste instead of trying to launch a local browser.
@@ -567,8 +642,8 @@ The above image is the first page of provider logins
### Supported provider
- **Native / first-party style providers:** `claude`, `openai`, `copilot`, `gemini`, `azure`, `alibaba-coding-plan`
-- **Aggregator / compatibility providers:** `openrouter`, `openai-compatible`
-- **Additional provider integrations:** `opencode`, `opencode-go`, `zai` / `kimi`, `302ai`, `baseten`, `cortecs`, `deepseek`, `firmware`, `huggingface`, `moonshotai`, `nebius`, `scaleway`, `stackit`, `groq`, `mistral`, `perplexity`, `togetherai`, `deepinfra`, `fireworks`, `minimax`, `xai`, `lmstudio`, `ollama`, `chutes`, `cerebras`, `cursor`, `antigravity`, `google`
+- **Aggregator / compatibility providers:** `openrouter`, `orcarouter`, `openai-compatible`
+- **Additional provider integrations:** `opencode`, `opencode-go`, `zai` / `kimi`, `302ai`, `baseten`, `cortecs`, `deepseek`, `firmware`, `huggingface`, `moonshotai`, `nebius`, `scaleway`, `stackit`, `groq`, `mistral`, `perplexity`, `togetherai`, `deepinfra`, `fireworks`, `novita`, `minimax`, `xai`, `lmstudio`, `ollama`, `chutes`, `cerebras`, `cursor`, `antigravity`, `google`
Jcode also supports easy multi-account switching. Ran out of tokens on your first ChatGPT Pro subscription? /account and quickly switch to your second.
@@ -704,6 +779,10 @@ Notes:
## Further Reading
+- [jcode.sh/docs](https://jcode.sh/docs) — install, providers, configuration, keybindings
+- [jcode.sh/swarm](https://jcode.sh/swarm) — many coding agents in one repository
+- [jcode.sh/sdk](https://jcode.sh/sdk) — TypeScript SDK: drive jcode sessions from your own program
+- [jcode.sh/bench](https://jcode.sh/bench) — benchmark methodology and results
- [Ambient Mode / OpenClaw](docs/AMBIENT_MODE.md)
- [Browser Provider Protocol](docs/BROWSER_PROVIDER_PROTOCOL.md)
- [Memory Architecture](docs/MEMORY_ARCHITECTURE.md)
@@ -759,6 +838,7 @@ Set up jcode on this machine for me.
- Azure OpenAI: `~/.config/jcode/azure-openai.env`, `AZURE_OPENAI_*`, or an existing `az login`
- OpenRouter: `OPENROUTER_API_KEY`
- Fireworks: `~/.config/jcode/fireworks.env`, `FIREWORKS_API_KEY`
+ - Novita AI: `~/.config/jcode/novita.env`, `NOVITA_API_KEY`
- MiniMax: `~/.config/jcode/minimax.env`, `MINIMAX_API_KEY`
- NVIDIA NIM: `~/.config/jcode/nvidia-nim.env`, `NVIDIA_API_KEY`
- Alibaba Cloud Coding Plan: existing jcode config/env if present
diff --git a/TELEMETRY.md b/TELEMETRY.md
index 5e5055a9f4..85ad064337 100644
--- a/TELEMETRY.md
+++ b/TELEMETRY.md
@@ -1,8 +1,52 @@
# jcode Telemetry
-jcode collects **anonymous, minimal usage statistics** to help understand how many people use jcode, what providers/models are popular, whether onboarding works, which feature families are used, how often sessions succeed, and whether performance/regressions are improving. This data helps prioritize development without collecting prompts or code.
-
-Recent telemetry additions also include: coarse onboarding steps, explicit thumbs-up / thumbs-down feedback, build-channel / dev-mode cleanup flags, session/workflow/tool-category summaries, coarse project language buckets, retention helpers like active days in the last 7 / 30 days, workflow cadence fields for session timing and multi-sessioning, privacy-safe per-turn timing/outcome metrics, and schema v5 agent-time / autonomy / pain-attribution metrics.
+jcode collects **anonymous, minimal usage statistics** to help understand how many people use jcode, what providers/models are popular, whether onboarding works, which feature families are used, how often sessions succeed, and whether performance/regressions are improving. This data helps prioritize development. Ordinary telemetry does **not** contain prompts, source code, model responses, or conversation transcripts.
+
+Jcode also offers a separate, optional transcript-sharing program. It is off by
+default and requires choosing **Share full transcripts** in the telemetry
+settings. This consent is independent of ordinary usage telemetry and is
+versioned so an older preference cannot silently opt a user into a newly
+introduced content program.
+
+### Optional Full Transcript Event
+
+When transcript sharing is explicitly enabled, one upload is queued when a
+non-empty session closes or crashes. The upload contains the complete structured
+conversation: user prompts, model responses and reasoning retained by Jcode,
+source code present in messages, tool names and inputs, and tool results. Images
+remain represented by their transcript content-block metadata; Jcode does not
+add local files that were not already present in the conversation.
+
+Before upload, Jcode recursively replaces likely credentials with
+`[REDACTED_SECRET]`. This covers sensitive JSON fields (API keys, tokens,
+passwords, authorization headers, cookies, private keys, and client secrets),
+known provider-token formats, bearer tokens, JWTs, AWS access-key IDs, private
+key blocks, and common environment-variable assignments. The receiving Worker
+runs the same classes of checks again before writing to R2. Ordinary source code
+is retained. Secret detection is defense in depth rather than a mathematical
+guarantee, so users should still avoid intentionally pasting live credentials.
+
+| Field | Purpose |
+|-------|---------|
+| `upload_id` | Random identifier for this upload |
+| `id` | Installation telemetry ID, used to honor deletion requests |
+| `consent_version` | Version of the explicit content-sharing consent |
+| `provider` / `model` / `end_reason` | Session metadata |
+| `message_count` / `messages` | Complete structured conversation |
+
+Transcript uploads use the dedicated `/v1/transcript` endpoint and are stored
+in a private R2 bucket, separate from Analytics Engine and ordinary D1 event
+rows. D1 stores only upload metadata and the private object key. Uploads are
+limited to 8 MiB and the R2 bucket must have a 30-day deletion lifecycle rule.
+Access should be restricted to specifically authorized maintainers working on
+quality evaluation. Transcript data must not be sold or shared with unrelated
+third parties.
+
+Disable transcript sharing at any time from `/telemetry` by selecting **No
+prompts or transcripts** or **Send nothing**. `JCODE_NO_TELEMETRY` and
+`DO_NOT_TRACK` override the content setting and prevent uploads.
+
+Recent telemetry additions also include: coarse onboarding steps, explicit thumbs-up / thumbs-down feedback, build-channel / dev-mode cleanup flags, session/workflow/tool-category summaries, coarse project language buckets, retention helpers like active days in the last 7 / 30 days, workflow cadence fields for session timing and multi-sessioning, privacy-safe per-turn timing/outcome metrics, schema v5 agent-time / autonomy / pain-attribution metrics, and numeric-only todo progress aggregates.
## What We Collect
@@ -51,10 +95,18 @@ Recent telemetry additions also include: coarse onboarding steps, explicit thumb
| Field | Example | Purpose |
|-------|---------|----------|
| `event` | `"feedback"` | Event type |
-| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback explicitly submitted with `/feedback ...` |
+| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback submitted with `/feedback ...` or the `maintainer_feedback` agent tool |
| `feedback_rating` | `"up"` / `"down"` | Legacy explicit product sentiment, if present |
| `feedback_reason` | `"slow"` | Legacy optional coarse reason bucket, if present |
+The `maintainer_feedback` tool is available only as another explicit telemetry
+path: it obeys the same telemetry opt-out as `/feedback` and sends no event when
+telemetry is disabled. Its schema tells the agent to paraphrase, omit secrets and
+private data, and label whether the report originated with the user, the agent,
+or both. User-originated and mixed reports are rejected unless the user explicitly
+approved sharing them; agent-only technical observations do not need per-report
+approval. Jcode does not attach transcript content, repository files, or paths.
+
### Sponsored Discovery Event
One event is sent after each `discover_tools` attempt. A random per-request ID
@@ -66,9 +118,9 @@ without exposing prompts or a persistent telemetry identifier to that service.
|-------|---------|----------|
| `event` | `"discovery"` | Event type |
| `request_id` | `"9a23..."` | Random correlation ID scoped to one request |
-| `phase` | `"browse"` / `"select"` / `"suggest"` / `"unknown"` | Discovery funnel stage; `suggest` records a missing catalog capability proposal |
+| `phase` | `"browse"` / `"details"` / `"select"` / `"suggest"` / `"unknown"` | Discovery funnel stage; `details` records investigation without selection and `suggest` records a missing catalog capability proposal |
| `category` | `"payments"` | Fixed discovery category, when valid |
-| `selected_tool` | `"agentcard"` | Public catalog tool name in the select phase |
+| `selected_tool` | `"agentcard"` | Public catalog tool name in the details or select phase |
| `outcome` | `"success"` / `"failure"` | Attempt result |
| `failure_reason` | `"timeout"` | Allowlisted coarse failure class only |
| `http_status` | `200` | Discovery service response status, if received |
@@ -88,6 +140,53 @@ The benchmark runner sets `JCODE_DISCOVERY_BENCHMARK=1`. Discovery requests then
carry `x-jcode-discovery-benchmark: 1`, and the corresponding telemetry event has
`benchmark_run: true`.
+When telemetry is enabled, discovery API requests also carry
+`x-jcode-session-correlation-id`. It is a fresh random UUID for the current
+runtime session, is not derived from the persistent telemetry ID, and is never
+reused across sessions. The same UUID appears on the numeric-only Todo Session
+event below. When telemetry is disabled, this header is omitted.
+
+### Todo Session Event
+
+This does not replace the todo counters added in migration 0021. Those live on
+`session_details` / `turn_details` and count how often todo gates fired
+(`tool_cat_todo`, `feature_todo_used`, `todo_gate_*_count`). This event is the
+complement: the lifecycle outcome of the list and the score values themselves,
+plus the per-session join key. Read 0021 for "how often did gates fire" and this
+event for "did the work finish, and how confident was the agent".
+
+One aggregate event is sent when an active session ends. Its `id` and
+`correlation_id` fields are the same fresh per-session UUID. The persistent
+telemetry ID, account ID, internal session ID, todo IDs, and all user/model text
+are absent, so the event is joinable to discovery requests from that session but
+not to an install, account, or another session.
+
+That join is not yet possible in practice. The discovery service stores its rows
+in the `jcode-subscriptions` D1 while this event lands in `jcode-telemetry`, and
+nothing on the receiving side reads
+`x-jcode-session-correlation-id` yet, so the header is currently sent and
+discarded. The correlation design is what makes the join possible later; it does
+not by itself make the number available.
+
+| Field | Type | Purpose |
+|-------|------|---------|
+| `event` | `"todo_session"` | Event type |
+| `id` / `correlation_id` | UUID strings | Same random, single-session join key; never the persistent telemetry ID |
+| `session_end_reason` | enum string | Coarse lifecycle end reason |
+| `todos_created` / `todos_completed` / `todos_abandoned` | non-negative integers | Todo lifecycle transitions and items ending non-complete |
+| `todo_updates` | non-negative integer | Number of todo tool calls |
+| `groups_completed` / `groups_total` | non-negative integers | Final coherent-goal completion summary |
+| `max_todo_list_size` | non-negative integer | Todo list high-water mark |
+| `confidence_min` / `confidence_mean` / `confidence_count` | number / number / integer | Distribution-safe current confidence summary |
+| `completion_confidence_min` / `completion_confidence_mean` / `completion_confidence_count` | number / number / integer | Distribution-safe completion-confidence summary |
+| `understands_user_intent_min` / `understands_user_intent_mean` / `understands_user_intent_count` | number / number / integer | Distribution-safe plan-level intent-understanding summary; count is 0 or 1 |
+| `closed_feedback_loop_min` / `closed_feedback_loop_mean` / `closed_feedback_loop_count` | number / number / integer | Distribution-safe per-goal feedback-loop score summary |
+| `end_to_end_ownership_min` / `end_to_end_ownership_mean` / `end_to_end_ownership_count` | number / number / integer | Distribution-safe per-goal ownership summary |
+| `schema_version` / `version` / `os` / `arch` / build flags | numbers, booleans, and enum/identifier strings | Compatibility and coarse release filtering |
+
+Todo content, goal labels, feedback-loop text, user-intention text, task content,
+file paths, code, prompts, item IDs, and per-item rows are **never sent**.
+
### Session Start Event
| Field | Example | Purpose |
@@ -148,7 +247,7 @@ carry `x-jcode-discovery-benchmark: 1`, and the corresponding telemetry event ha
| `total_tokens` | `23223` | Sum of input, output, cache-read, and cache-creation tokens |
| `feature_*_used` | `true/false` | Whether a feature family was used (memory, swarm, web, email, MCP, side panel, goals, todos, selfdev, background, subagents) |
| `tool_cat_*` | `0..N` | Coarse tool category counts (read/search, write, shell, web, memory, subagent, swarm, email, side-panel, goal, todo, MCP, other) |
-| `todo_gate_*_count` | `0..N` | How often todo quality gates fired in-session (end-to-end ownership, hill-climbability, completion confidence, confidence spike) |
+| `todo_gate_*_count` | `0..N` | How often todo quality gates fired in-session (end-to-end ownership, closed feedback loop, completion confidence, confidence spike) |
| `command_*_used` | `true/false` | Whether a slash-command family was used in-session |
| `workflow_*_used` | `true/false` | Whether the session looked like coding, research, testing, background, subagent, or swarm work |
| `unique_mcp_servers` | `2` | Count of distinct MCP servers touched in-session |
@@ -227,24 +326,62 @@ Most events also carry a few coarse quality / cleanup fields:
| `event_id` | `"uuid"` | Deduplication |
| `session_id` | `"uuid"` | Joins session-scoped events together |
| `schema_version` | `3` | Forward-compatible parsing |
-| `build_channel` | `"release"` / `"selfdev"` / `"local_build"` | Filter out dev/test usage |
+| `build_channel` | `"release"` / `"ci_release"` / `"selfdev"` / `"local_build"` | Separate installed releases, CI/CD-built releases, and dev/test usage |
| `is_git_checkout` | `true/false` | Distinguish source-tree usage from installed usage |
| `is_ci` | `true/false` | Filter CI noise |
| `ran_from_cargo` | `true/false` | Filter local dev launches |
+CI/CD jobs should set `JCODE_CI=1` when running jcode. `JCODE_CI=0` explicitly
+marks a run as non-CI and overrides inherited provider variables. When this
+setting is absent, jcode falls back to common provider markers such as `CI`,
+`GITHUB_ACTIONS`, `GITLAB_CI`, and `BUILDKITE`. Build provenance is independent:
+official release workflows set `JCODE_CI_BUILD=1` while compiling, producing the
+`ci_release` channel without classifying later end-user executions as CI.
+
## What We Do NOT Collect
+- **No conversation transcripts, prompts, code, or LLM responses**, except text you explicitly submit with `/feedback ...`
- No file paths, project names, or directory structures
-- No code, prompts, or LLM responses, except text explicitly submitted with `/feedback ...`
- No tool inputs or tool outputs
- No MCP server names or configurations
-- No IP addresses (Cloudflare Workers don't log these by default)
+- No IP addresses (the worker never reads or stores the client IP)
+- No city, region, coordinates, postal code, or timezone
- No personal information of any kind
- No error messages or stack traces in telemetry (only coarse categories and end reasons)
- No exact wall-clock timestamps beyond coarse hour-of-day / weekday buckets
+### Coarse Geography (added by the receiving worker, not the client)
+
+The telemetry worker records a single **2-letter country code**, resolved by
+Cloudflare at the edge from the connection (`request.cf.country`). jcode itself
+never collects, computes, or sends location data, and the value cannot be set or
+spoofed by the client.
+
+| Field | Example | Purpose |
+|-------|---------|----------|
+| `country` | `"DE"` | Aggregate "which countries do users come from" reporting |
+
+Only the country is kept: the IP address, city, region, coordinates, postal
+code, and timezone are never read or stored. It is stored as a per-day
+aggregate (`country_daily` counts) plus a `last_country` column on the daily
+active-user rollup. Unknown (`XX`) and Tor (`T1`) codes are discarded.
+
The UUID is randomly generated on first run and stored at `~/.jcode/telemetry_id`. It is not derived from your machine, username, email, or any identifiable information.
+## How We Use and Share Data
+
+Telemetry is used to operate, debug, secure, and improve jcode, and for product and
+retention analytics. We may publish or share **aggregate** statistics (for example
+install counts, OS/provider distribution, version adoption) and we share data with the
+infrastructure providers needed to run the pipeline, currently Cloudflare.
+
+We do **not** sell event-level telemetry, and we do not collect conversation content to
+sell or to train models. If that ever changes, it will be a separate, clearly disclosed,
+**opt-in** program rather than a silent change to this document.
+
+We do not attempt to re-identify users from telemetry, and the client does not link
+telemetry to account identity.
+
## How It Works
1. On first launch, jcode generates a random UUID and sends an `install` event
@@ -259,6 +396,12 @@ The UUID is randomly generated on first run and stored at `~/.jcode/telemetry_id
The telemetry endpoint is a Cloudflare Worker that stores events in a D1 database. The source code for the worker is in [`telemetry-worker/`](./telemetry-worker/).
+## Changes to This Policy
+
+The version of this document in the repository is the current policy. If we ever want to
+collect conversation content, or to share or sell anything beyond aggregate statistics,
+that will require a separate opt-in rather than a quiet edit here.
+
### Schema v5 deployment note
Agent-time, autonomy, and pain-attribution fields require the D1 migration in `telemetry-worker/migrations/0008_agent_time_and_churn.sql`. Until that migration is applied, schema v5 clients can still send the new JSON payloads, but the worker will drop unknown columns through dynamic column filtering and dashboard agent-time panels will remain empty or show optional-panel errors. After migration, run/redeploy the telemetry worker and query the dashboard's **Agent time / autonomy** panel.
@@ -268,13 +411,23 @@ Agent-time, autonomy, and pain-attribution fields require the D1 migration in `t
Any of these methods will disable telemetry completely:
```bash
-# Option 1: Environment variable
+# Option 1: Persistent CLI setting
+jcode telemetry disable
+
+# Inspect the current setting without creating a telemetry ID
+jcode telemetry status
+jcode telemetry status --json
+
+# Re-enable telemetry
+jcode telemetry enable
+
+# Option 2: Environment variable
export JCODE_NO_TELEMETRY=1
-# Option 2: Standard DO_NOT_TRACK (https://consoledonottrack.com/)
+# Option 3: Standard DO_NOT_TRACK (https://consoledonottrack.com/)
export DO_NOT_TRACK=1
-# Option 3: File-based opt-out
+# Option 4: File-based opt-out
touch ~/.jcode/no_telemetry
```
@@ -282,10 +435,10 @@ When opted out, zero network requests are made. The telemetry module short-circu
## Verification
-This is open source. The entire telemetry implementation is in [`src/telemetry.rs`](./src/telemetry.rs) - you can read exactly what gets sent. There are no other network calls related to telemetry anywhere in the codebase.
+This is open source. The telemetry implementation is in [`crates/jcode-telemetry-core/src/`](./crates/jcode-telemetry-core/src/) - you can read exactly what gets sent. There are no other network calls related to telemetry anywhere in the codebase.
## Data Retention
-Telemetry data is used in aggregate only (install count, active users, provider distribution, session success/crash rates, feature-level counts). Individual event records are retained for up to 12 months and then deleted.
+Telemetry data is used in aggregate (install count, active users, provider distribution, session success/crash rates, feature-level counts). Individual event records are retained for up to 12 months and then deleted.
High-volume raw events are pruned earlier on a nightly schedule, after their aggregate signal has been captured in a compact daily-activity rollup: per-turn and per-session-start records and onboarding-step records are kept for about 30 days, upgrade records for about 60 days, and auth-success records for about 180 days. Session summary records (the per-session aggregate counts described above) are kept for up to 12 months.
diff --git a/changelog/index.json b/changelog/index.json
index 3324c1feca..a90f095942 100644
--- a/changelog/index.json
+++ b/changelog/index.json
@@ -1,22 +1,304 @@
{
"entries": [
- { "version": "0.50.0", "date": "2026-07-17" },
- { "version": "0.49.0", "date": "2026-07-16" },
- { "version": "0.48.0", "date": "2026-07-15" },
- { "version": "0.47.0", "date": "2026-07-14" },
- { "version": "0.46.0", "date": "2026-07-13" },
- { "version": "0.45.0", "date": "2026-07-13" },
- { "version": "0.44.0", "date": "2026-07-12" },
- { "version": "0.43.0", "date": "2026-07-11" },
- { "version": "0.42.0", "date": "2026-07-11" },
- { "version": "0.41.0", "date": "2026-07-10" },
- { "version": "0.40.0", "date": "2026-07-10" },
- { "version": "0.39.0", "date": "2026-07-09" },
- { "version": "0.38.0", "date": "2026-07-09" },
- { "version": "0.37.0", "date": "2026-07-07" },
- { "version": "0.36.0", "date": "2026-07-05" },
- { "version": "0.35.1", "date": "2026-07-04" },
- { "version": "0.35.0", "date": "2026-07-04" },
- { "version": "0.34.0", "date": "2026-07-02" }
+ {
+ "version": "0.84.0",
+ "date": "2026-09-06"
+ },
+ {
+ "version": "0.83.0",
+ "date": "2026-09-06"
+ },
+ {
+ "version": "0.82.0",
+ "date": "2026-09-06"
+ },
+ {
+ "version": "0.81.7",
+ "date": "2026-09-04"
+ },
+ {
+ "version": "0.81.6",
+ "date": "2026-09-03"
+ },
+ {
+ "version": "0.81.5",
+ "date": "2026-09-03"
+ },
+ {
+ "version": "0.81.4",
+ "date": "2026-08-30"
+ },
+ {
+ "version": "0.81.3",
+ "date": "2026-08-29"
+ },
+ {
+ "version": "0.81.2",
+ "date": "2026-08-28"
+ },
+ {
+ "version": "0.81.1",
+ "date": "2026-08-25"
+ },
+ {
+ "version": "0.81.0",
+ "date": "2026-08-25"
+ },
+ {
+ "version": "0.80.1",
+ "date": "2026-08-25"
+ },
+ {
+ "version": "0.80.0",
+ "date": "2026-08-24"
+ },
+ {
+ "version": "0.79.1",
+ "date": "2026-08-21"
+ },
+ {
+ "version": "0.79.0",
+ "date": "2026-08-21"
+ },
+ {
+ "version": "0.78.1",
+ "date": "2026-08-19"
+ },
+ {
+ "version": "0.78.0",
+ "date": "2026-08-18"
+ },
+ {
+ "version": "0.77.2",
+ "date": "2026-08-18"
+ },
+ {
+ "version": "0.77.1",
+ "date": "2026-08-17"
+ },
+ {
+ "version": "0.77.0",
+ "date": "2026-08-17"
+ },
+ {
+ "version": "0.76.0",
+ "date": "2026-08-14"
+ },
+ {
+ "version": "0.75.5",
+ "date": "2026-08-12"
+ },
+ {
+ "version": "0.75.4",
+ "date": "2026-08-12"
+ },
+ {
+ "version": "0.75.3",
+ "date": "2026-08-11"
+ },
+ {
+ "version": "0.75.2",
+ "date": "2026-08-11"
+ },
+ {
+ "version": "0.75.1",
+ "date": "2026-08-11"
+ },
+ {
+ "version": "0.75.0",
+ "date": "2026-08-10"
+ },
+ {
+ "version": "0.74.0",
+ "date": "2026-08-10"
+ },
+ {
+ "version": "0.73.0",
+ "date": "2026-08-09"
+ },
+ {
+ "version": "0.71.1",
+ "date": "2026-08-08"
+ },
+ {
+ "version": "0.71.0",
+ "date": "2026-08-06"
+ },
+ {
+ "version": "0.70.1",
+ "date": "2026-08-06"
+ },
+ {
+ "version": "0.70.0",
+ "date": "2026-08-06"
+ },
+ {
+ "version": "0.69.0",
+ "date": "2026-08-06"
+ },
+ {
+ "version": "0.68.0",
+ "date": "2026-08-05"
+ },
+ {
+ "version": "0.67.1",
+ "date": "2026-08-03"
+ },
+ {
+ "version": "0.67.0",
+ "date": "2026-08-03"
+ },
+ {
+ "version": "0.66.0",
+ "date": "2026-08-03"
+ },
+ {
+ "version": "0.64.2",
+ "date": "2026-07-30"
+ },
+ {
+ "version": "0.64.1",
+ "date": "2026-07-30"
+ },
+ {
+ "version": "0.64.0",
+ "date": "2026-07-30"
+ },
+ {
+ "version": "0.61.1",
+ "date": "2026-07-28"
+ },
+ {
+ "version": "0.61.0",
+ "date": "2026-07-27"
+ },
+ {
+ "version": "0.60.0",
+ "date": "2026-07-26"
+ },
+ {
+ "version": "0.59.0",
+ "date": "2026-07-25"
+ },
+ {
+ "version": "0.58.0",
+ "date": "2026-07-25"
+ },
+ {
+ "version": "0.57.0",
+ "date": "2026-07-24"
+ },
+ {
+ "version": "0.56.0",
+ "date": "2026-07-24"
+ },
+ {
+ "version": "0.55.0",
+ "date": "2026-07-22"
+ },
+ {
+ "version": "0.54.4",
+ "date": "2026-07-20"
+ },
+ {
+ "version": "0.54.3",
+ "date": "2026-07-20"
+ },
+ {
+ "version": "0.54.2",
+ "date": "2026-07-20"
+ },
+ {
+ "version": "0.54.1",
+ "date": "2026-07-20"
+ },
+ {
+ "version": "0.54.0",
+ "date": "2026-07-20"
+ },
+ {
+ "version": "0.53.0",
+ "date": "2026-07-19"
+ },
+ {
+ "version": "0.52.0",
+ "date": "2026-07-19"
+ },
+ {
+ "version": "0.51.1",
+ "date": "2026-07-18"
+ },
+ {
+ "version": "0.50.0",
+ "date": "2026-07-17"
+ },
+ {
+ "version": "0.49.0",
+ "date": "2026-07-16"
+ },
+ {
+ "version": "0.48.0",
+ "date": "2026-07-15"
+ },
+ {
+ "version": "0.47.0",
+ "date": "2026-07-14"
+ },
+ {
+ "version": "0.46.0",
+ "date": "2026-07-13"
+ },
+ {
+ "version": "0.45.0",
+ "date": "2026-07-13"
+ },
+ {
+ "version": "0.44.0",
+ "date": "2026-07-12"
+ },
+ {
+ "version": "0.43.0",
+ "date": "2026-07-11"
+ },
+ {
+ "version": "0.42.0",
+ "date": "2026-07-11"
+ },
+ {
+ "version": "0.41.0",
+ "date": "2026-07-10"
+ },
+ {
+ "version": "0.40.0",
+ "date": "2026-07-10"
+ },
+ {
+ "version": "0.39.0",
+ "date": "2026-07-09"
+ },
+ {
+ "version": "0.38.0",
+ "date": "2026-07-09"
+ },
+ {
+ "version": "0.37.0",
+ "date": "2026-07-07"
+ },
+ {
+ "version": "0.36.0",
+ "date": "2026-07-05"
+ },
+ {
+ "version": "0.35.1",
+ "date": "2026-07-04"
+ },
+ {
+ "version": "0.35.0",
+ "date": "2026-07-04"
+ },
+ {
+ "version": "0.34.0",
+ "date": "2026-07-02"
+ }
]
}
diff --git a/changelog/v0.51.1.json b/changelog/v0.51.1.json
new file mode 100644
index 0000000000..90e2fcae6f
--- /dev/null
+++ b/changelog/v0.51.1.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.51.1",
+ "date": "2026-07-18",
+ "title": "Safer live Claude Code takeover",
+ "fixes": [
+ "Taking over a live Claude Code session now signals the exact verified process, so an unrelated process that reused the PID can never be stopped by mistake",
+ "Takeover refuses transcripts that belong to a different Claude session instead of importing the wrong conversation",
+ "If Claude Code does not exit in time during takeover, the prepared Jcode session is preserved and can still be resumed",
+ "The final transcript refresh after takeover is retried, so the last messages Claude flushes on exit are no longer silently dropped"
+ ]
+}
diff --git a/changelog/v0.52.0.json b/changelog/v0.52.0.json
new file mode 100644
index 0000000000..da817c6340
--- /dev/null
+++ b/changelog/v0.52.0.json
@@ -0,0 +1,33 @@
+{
+ "version": "0.52.0",
+ "date": "2026-07-19",
+ "title": "ChatGPT web models, faster /model search, and a calmer TUI",
+ "highlights": [
+ "New browser-backed ChatGPT web route adds gpt-5.6-pro[web], letting Pro-tier web models run through your logged-in browser",
+ "/model fuzzy search is dramatically faster and no longer lags while typing",
+ "Opening the / slash-command palette no longer shifts the screen, and the menu renders as an overlay"
+ ],
+ "improvements": [
+ "Platform-API-only GPT Pro models now appear in the /model picker",
+ "All providers now expose their complete reasoning effort ladders",
+ "macOS menu bar sessions are labeled by their active work and show only your root sessions",
+ "New display.overscroll_status config (off/on/overscroll) and overscroll now requires a gesture starting at the transcript bottom",
+ "New JCODE_PERF_TIER environment variable forces a performance tier for one invocation",
+ "Auto-poke stops after consecutive provider guardrail refusals instead of retrying forever",
+ "Tool calls show a short intent line by default, with technical detail behind display.tool_call_details",
+ "Long-idle sessions periodically release retained memory back to the OS"
+ ],
+ "fixes": [
+ "Copy uses native Linux and macOS clipboards before falling back to OSC 52",
+ "Update checks authenticate to GitHub when possible, avoiding rate-limit failures on shared IPs",
+ "/fast set to off now persists instead of reverting to the default",
+ "Memory tool results are scoped to the session working directory instead of leaking across projects",
+ "PascalCase OAuth tool aliases now resolve inside batch subcalls",
+ "Windows server spawn no longer times out prematurely on slow machines",
+ "Desktop attach no longer blocks on a busy session",
+ "PowerShell install scripts no longer break from a UTF-8 BOM",
+ "Logging in no longer activates the same profile twice",
+ "Split-pane launches keep their elapsed-time display",
+ "Bash commands no longer spill onto a second line when an intent is shown"
+ ]
+}
diff --git a/changelog/v0.53.0.json b/changelog/v0.53.0.json
new file mode 100644
index 0000000000..ec28f06cc6
--- /dev/null
+++ b/changelog/v0.53.0.json
@@ -0,0 +1,21 @@
+{
+ "version": "0.53.0",
+ "date": "2026-07-19",
+ "title": "GitHub triage, copyable math, and sturdier reloads",
+ "highlights": [
+ "New built-in /triage command helps review and organize GitHub issues from a jcode session",
+ "Rendered LaTeX equations can now be copied as their original math source instead of opaque image markers"
+ ],
+ "improvements": [
+ "Bursty response streams reveal more smoothly while keeping the terminal responsive",
+ "Desktop resizing and hot reloads use fewer unnecessary redraws, react promptly to worker activity, and preserve the final window position and size"
+ ],
+ "fixes": [
+ "Remote skill prompts are dispatched correctly instead of remaining stuck in the client",
+ "Noninteractive Google sign-in now completes OAuth callbacks reliably",
+ "Windows updates and server startup avoid launcher replacement and named-pipe probe stalls",
+ "Terminal images choose a reliable protocol for each terminal instead of reusing an incompatible global choice",
+ "Swarm clients drop cleared plans, omit long-finished members, bound durable plan graphs, and prune stale recovery state",
+ "Exited jcode client processes are reaped instead of accumulating in the background"
+ ]
+}
diff --git a/changelog/v0.54.0.json b/changelog/v0.54.0.json
new file mode 100644
index 0000000000..32e9036947
--- /dev/null
+++ b/changelog/v0.54.0.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.54.0",
+ "date": "2026-07-20",
+ "title": "Subscription models, session facts, and faster Windows startup",
+ "highlights": [
+ "Jcode subscription routes now include curated Bedrock coding models and appear reliably in model selection and provider diagnostics",
+ "Session facts now use available space beside the conversation while remaining grouped and readable",
+ "The Windows hotkey listener now prewarms the background server for faster first launches"
+ ],
+ "improvements": [
+ "Terminals without inline image support now explain the fallback, preserve image interactions, and show Mermaid source when rendering is unavailable",
+ "Long sessions use less memory by releasing transcript copies, trimming inactive clients, and unloading the embedding model sooner",
+ "Windows startup and credential maintenance avoid repeated or blocking security work"
+ ],
+ "fixes": [
+ "The input view follows new content to the bottom consistently",
+ "Swarm recursion is limited to deep-swarm roots, live agent counts remain bounded, and resumed sessions avoid a lock inversion",
+ "Mixed provider and subscription routes survive catalog hydration, stale catalogs refresh correctly, and direct Bedrock routes remain available",
+ "Bedrock uses configured AWS profiles while authentication and remote catalog inputs stay within their intended security scope",
+ "Onboarding credentials and deferred Windows ACL retries are handled more safely",
+ "Transport and PDF dependencies include current security fixes"
+ ]
+}
diff --git a/changelog/v0.54.1.json b/changelog/v0.54.1.json
new file mode 100644
index 0000000000..f8fc753410
--- /dev/null
+++ b/changelog/v0.54.1.json
@@ -0,0 +1,15 @@
+{
+ "version": "0.54.1",
+ "date": "2026-07-20",
+ "title": "Portable builds and native terminal math",
+ "highlights": [
+ "LaTeX expressions render natively in Handterm-capable terminals"
+ ],
+ "improvements": [
+ "Header rendering reuses authentication state to reduce repeated provider work"
+ ],
+ "fixes": [
+ "Portable Linux downloads build with a current bundled OpenSSL while retaining compatibility with older glibc systems",
+ "FreeBSD release builds compile setup guidance correctly"
+ ]
+}
diff --git a/changelog/v0.54.2.json b/changelog/v0.54.2.json
new file mode 100644
index 0000000000..99c632c61e
--- /dev/null
+++ b/changelog/v0.54.2.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.54.2",
+ "date": "2026-07-20",
+ "title": "Subscription models, native terminal math, and portable builds",
+ "highlights": [
+ "Jcode subscription routes include curated Bedrock coding models and appear reliably in model selection and provider diagnostics",
+ "LaTeX expressions render natively in Handterm-capable terminals",
+ "The Windows hotkey listener prewarms the background server for faster first launches"
+ ],
+ "improvements": [
+ "Session facts use available space beside the conversation while remaining grouped and readable",
+ "Terminals without inline image support explain the fallback, preserve image interactions, and show Mermaid source when rendering is unavailable",
+ "Long sessions use less memory by releasing transcript copies, trimming inactive clients, and unloading the embedding model sooner"
+ ],
+ "fixes": [
+ "Portable Linux downloads use a current bundled OpenSSL while retaining compatibility with older glibc systems",
+ "FreeBSD release builds compile setup guidance correctly",
+ "Remote skill prompts route without relying on a checked panic path",
+ "The input view follows new content to the bottom consistently",
+ "Swarm recursion stays limited to deep-swarm roots, live agent counts remain bounded, and resumed sessions avoid a lock inversion",
+ "Mixed provider and subscription routes survive catalog hydration while Bedrock authentication and catalog inputs remain within their intended security scope"
+ ]
+}
diff --git a/changelog/v0.54.3.json b/changelog/v0.54.3.json
new file mode 100644
index 0000000000..13178f2684
--- /dev/null
+++ b/changelog/v0.54.3.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.54.3",
+ "date": "2026-07-20",
+ "title": "Subscription models, native terminal math, and portable builds",
+ "highlights": [
+ "Jcode subscription routes include curated Bedrock coding models and appear reliably in model selection and provider diagnostics",
+ "LaTeX expressions render natively in Handterm-capable terminals",
+ "The Windows hotkey listener prewarms the background server for faster first launches"
+ ],
+ "improvements": [
+ "Session facts use available space beside the conversation while remaining grouped and readable",
+ "Terminals without inline image support explain the fallback, preserve image interactions, and show Mermaid source when rendering is unavailable",
+ "Long sessions use less memory by releasing transcript copies, trimming inactive clients, and unloading the embedding model sooner"
+ ],
+ "fixes": [
+ "Portable Linux downloads use a current bundled OpenSSL while retaining compatibility with older glibc systems",
+ "FreeBSD release builds compile setup guidance correctly",
+ "Remote skill prompts route without relying on a checked panic path",
+ "The input view follows new content to the bottom consistently",
+ "Swarm recursion stays limited to deep-swarm roots, live agent counts remain bounded, and resumed sessions avoid a lock inversion",
+ "Mixed provider and subscription routes survive catalog hydration while Bedrock authentication and catalog inputs remain within their intended security scope"
+ ]
+}
diff --git a/changelog/v0.54.4.json b/changelog/v0.54.4.json
new file mode 100644
index 0000000000..f12df021d4
--- /dev/null
+++ b/changelog/v0.54.4.json
@@ -0,0 +1,10 @@
+{
+ "version": "0.54.4",
+ "date": "2026-07-20",
+ "title": "Complete cross-platform release artifacts",
+ "fixes": [
+ "Restores the portable Linux x86_64 download by including the complete runtime needed to build bundled OpenSSL",
+ "Windows x86_64 and ARM64 downloads publish reliably from the release pipeline",
+ "Release publication now remains gated until every Linux, macOS, Windows, and FreeBSD artifact is available"
+ ]
+}
diff --git a/changelog/v0.55.0.json b/changelog/v0.55.0.json
new file mode 100644
index 0000000000..ae8adc5a9b
--- /dev/null
+++ b/changelog/v0.55.0.json
@@ -0,0 +1,25 @@
+{
+ "version": "0.55.0",
+ "date": "2026-07-22",
+ "title": "Terminal panes, expanded model catalog, and smarter task tracking",
+ "highlights": [
+ "Terminal spawns inside tmux now open in split panes instead of new windows",
+ "Expanded Jcode Bedrock subscription model catalog",
+ "Todo planning now measures how well objectives and feedback loops match your request"
+ ],
+ "improvements": [
+ "New /subscribe command with helpful nudges at rate-limit and long-task moments",
+ "New no-emoji output option keeps responses emoji-free when configured",
+ "Unreliable Llama tool-calling routes and the unstable Nemotron Super route are excluded from the model picker"
+ ],
+ "fixes": [
+ "Interactive model switching across providers works again",
+ "OpenAI models in the model picker no longer show broken placeholder routes",
+ "Structured rate limit errors are formatted readably",
+ "Auto-retry stops promptly when an OpenAI usage limit is reached",
+ "Slash command palette no longer flickers from spinner redraws",
+ "Long-lived OpenAI websocket sessions keep their prompt cache alive",
+ "Native compaction correctly resets the cache baseline, avoiding wasted tokens",
+ "Todo completion follow-ups are delivered reliably"
+ ]
+}
diff --git a/changelog/v0.56.0.json b/changelog/v0.56.0.json
new file mode 100644
index 0000000000..dc48072cb8
--- /dev/null
+++ b/changelog/v0.56.0.json
@@ -0,0 +1,22 @@
+{
+ "version": "0.56.0",
+ "date": "2026-07-24",
+ "title": "Redesigned header, faster pickers, and a harness API",
+ "highlights": [
+ "Redesigned TUI header: left-aligned, shows git branch beside the working directory, vertical auth list, and capped MCP/skills lines",
+ "Much faster fuzzy filtering in pickers, with instant /model open for large remote catalogs",
+ "New versioned harness API socket lets external apps drive jcode sessions"
+ ],
+ "improvements": [
+ "/thinking-display is now the primary command for thinking visibility",
+ "Unseen-updates box moved above the jcode line and renders inside startup padding",
+ "'/login to add provider:' heading lists unconfigured providers inline",
+ "Initial empty screen stays put so the first prompt no longer shifts content",
+ "Info widgets settle into stable placements instead of jumping around",
+ "Server/client header lines are dimmed for less visual noise"
+ ],
+ "fixes": [
+ "MCP servers owned by a session now run in that session's working directory and stay out of the shared pool",
+ "Copilot Sonnet 5 requests send the correct reasoning effort"
+ ]
+}
diff --git a/changelog/v0.57.0.json b/changelog/v0.57.0.json
new file mode 100644
index 0000000000..a99de18abb
--- /dev/null
+++ b/changelog/v0.57.0.json
@@ -0,0 +1,26 @@
+{
+ "version": "0.57.0",
+ "date": "2026-07-24",
+ "title": "Triage batch: model limits, streaming, and rendering fixes",
+ "highlights": [
+ "Claude Opus 5 is now in the built-in Anthropic catalog and works without manual configuration",
+ "Anthropic requests derive their max output tokens per model instead of a flat 32K cap",
+ "OpenRouter streaming no longer drops content when responses arrive with CRLF or multi-line SSE events"
+ ],
+ "improvements": [
+ "Todo plans ask for user intent once at the plan level instead of per item",
+ "Requests show a distinct 'sending request' phase instead of sitting on 'connecting'",
+ "Thinking/reasoning display is off by default for new users",
+ "The /login heading lists unconfigured providers as dim rows for a cleaner header",
+ "/model requests the route-expanded catalog and rebuilds an already-open picker"
+ ],
+ "fixes": [
+ "Kimi K3 now resolves to its full 1M context window everywhere",
+ "The context meter reflects catalog context limits for Anthropic models",
+ "Failed LaTeX renders are cached so redraws stop respawning latex processes",
+ "PDF extraction failures are recovered instead of crashing the session",
+ "Swarm history truncation no longer panics on multi-byte characters",
+ "Anthropic OAuth sessions no longer advertise built-in tools that are not available",
+ "Desktop builds no longer break on Windows from Unix-only IPC"
+ ]
+}
diff --git a/changelog/v0.58.0.json b/changelog/v0.58.0.json
new file mode 100644
index 0000000000..914b180812
--- /dev/null
+++ b/changelog/v0.58.0.json
@@ -0,0 +1,26 @@
+{
+ "version": "0.58.0",
+ "date": "2026-07-25",
+ "title": "Max-effort reasoning streams, model context limits, and the /model picker",
+ "highlights": [
+ "OpenAI max-effort reasoning turns no longer time out mid-thought: requests ask for reasoning summaries and the idle budget scales with reasoning effort",
+ "The /model picker shows pretty model names and matches queries like \"opus 4.8\" against them",
+ "New and unrecognized models no longer silently fall back to a 200K context window"
+ ],
+ "improvements": [
+ "Streaming idle and websocket completion budgets scale with reasoning effort (high 2x, xhigh 3x, max 4x) so long silent thinks are not mistaken for dead connections",
+ "The client-side stall guard budgets against the largest effort-scaled server timeout, so the server's visible error always fires first",
+ "Update failure messages collapse to one short line instead of a wall of text",
+ "Update checks back off from GitHub API rate limits instead of retrying in a loop",
+ "Spawned swarm workers fail loudly when they cannot use their requested model"
+ ],
+ "fixes": [
+ "Config-declared models survive a live catalog refresh",
+ "Named profiles that point elsewhere are treated as user-declared rather than built-in",
+ "Antigravity self-heals the missing thought_signature dead-end instead of getting stuck",
+ "A home-directory subscribe no longer clobbers the working directory of a project session",
+ "Kimi ids with an explicit -256k suffix resolve to 256K instead of the family default",
+ "JCODE_TOOL_CALL_DETAILS now invalidates the cached config when it changes",
+ "Fuzzy matching no longer rejects late matches because of the positional penalty"
+ ]
+}
diff --git a/changelog/v0.59.0.json b/changelog/v0.59.0.json
new file mode 100644
index 0000000000..5eb4e5855e
--- /dev/null
+++ b/changelog/v0.59.0.json
@@ -0,0 +1,25 @@
+{
+ "version": "0.59.0",
+ "date": "2026-07-25",
+ "title": "Desktop preview and provider fixes",
+ "highlights": [
+ "The desktop preview (jcode-desktop2) now starts the jcode runtime and harness bridge itself, so it connects on first launch instead of requiring two daemons to be started by hand",
+ "The desktop preview gained an animated, draggable hero donut on an empty session, a focus-aware caret and input border, and soft-wrapping multi-line composer; set JCODE_DESKTOP2_DONUT=0 to turn the motion off",
+ "New Celeris provider support"
+ ],
+ "improvements": [
+ "Model names render structurally for AWS Bedrock, including bare and context-variant revisions",
+ "The auth list hides providers you have no credentials for",
+ "Webfetch extracts pages more cleanly: site chrome is stripped, output is capped, and attribute text no longer leaks into the result",
+ "Snapshot dates render compactly and acronyms are uppercased in the TUI",
+ "The desktop preview remembers window geometry, supports mouse and keyboard text selection, and centres the composer text in its well",
+ "Onboarding opens the bug-review action instantly, and gained a telemetry settings screen"
+ ],
+ "fixes": [
+ "Bare OpenAI-compatible and named-profile model ids route to the right provider profile again",
+ "Only versioned GPT, Claude, and Gemini names are prettified in the model picker, so custom ids show as typed",
+ "Terminal escape remnants pasted at the composer boundary are stripped instead of corrupting input",
+ "Swarm ids and MCP discovery bind to the accepted working directory, fixing cross-directory mixups",
+ "Stranded agent continuations are recovered rather than silently stalling a turn"
+ ]
+}
diff --git a/changelog/v0.60.0.json b/changelog/v0.60.0.json
new file mode 100644
index 0000000000..b661d808ee
--- /dev/null
+++ b/changelog/v0.60.0.json
@@ -0,0 +1,18 @@
+{
+ "version": "0.60.0",
+ "date": "2026-07-26",
+ "title": "Discovery repair and desktop preview polish",
+ "highlights": [
+ "Tool discovery works again for users whose config was frozen while discovery shipped opt-in, so discover_tools is registered instead of staying silently unavailable",
+ "The desktop preview shows which provider and model is answering, and greets an empty session with an animated halftone donut and rotating composer hints"
+ ],
+ "improvements": [
+ "Discovery gained a financial-data category for market, pricing, and company-data tools",
+ "Keybinding hints show ⌥ instead of Alt on macOS",
+ "The desktop preview lays its text out on a consistent vertical rhythm that holds up at any window size",
+ "A hand-written discovery opt-out is still respected, and saving your config no longer bakes today's defaults into it"
+ ],
+ "fixes": [
+ "Idle animations no longer drag the terminal UI into unnecessary full redraws"
+ ]
+}
diff --git a/changelog/v0.61.0.json b/changelog/v0.61.0.json
new file mode 100644
index 0000000000..0a0c5cd7c7
--- /dev/null
+++ b/changelog/v0.61.0.json
@@ -0,0 +1,33 @@
+{
+ "version": "0.61.0",
+ "date": "2026-07-27",
+ "title": "Remote sessions, a destructive-command gate, and a faster desktop preview",
+ "highlights": [
+ "/remote is a new front door for reaching a running session from another machine over the WebSocket gateway",
+ "Destructive shell commands like recursive deletes now pause for an explicit reflection step before running, closing several bypass routes found in review",
+ "The desktop preview gained selectable transcript text, an Alt spatial session overview, live reasoning and tool-intent streaming, and much smoother scrolling"
+ ],
+ "improvements": [
+ "Quality-gate reminders about open todos now arrive once at the end of a turn instead of nagging after every write",
+ "Interactive model switching works across providers instead of failing when the target model lives on a different provider",
+ "Reasoning effort options are shown for OpenAI-compatible routes that support them, and token usage is reported for their chat streams",
+ "Failing OpenAI-compatible model-catalog refreshes back off instead of retrying hot, and each named provider keeps its own model cache",
+ "A stall watchdog logs long silent hangs during active work so they can be diagnosed",
+ "The slash-command registry was cleaned up: /keys no longer collides, missing aliases were restored, and help gaps were filled"
+ ],
+ "fixes": [
+ "Streaming responses no longer lose data when server-sent events split across chunk boundaries",
+ "A turn that stops expecting a tool call but delivers none now recovers instead of stranding the session",
+ "Pressing Enter immediately after pasting no longer sends the message prematurely",
+ "/clear also clears the side panel",
+ "Copilot retries transient 5xx token-exchange failures and sends the Sonnet reasoning effort correctly",
+ "Configured context windows are honored instead of being overridden by a GPT fallback",
+ "MCP servers started for a session now run in that session's working directory",
+ "agentgrep respects the file field when scoping grep and find",
+ "Session restore no longer hard-pins the Claude/OpenAI OAuth route, and a failed OpenRouter switch preserves the previous profile",
+ "Anthropic requests that would end on an assistant turn get a continuation turn instead of erroring",
+ "A dead terminal no longer mislabels a live session as crashed, and terminal state is restored safely on exit",
+ "Rendered LaTeX math recovers after its image cache is evicted",
+ "Quoted shell arguments are preserved on Windows, and the desktop build no longer breaks on Windows over Unix-only IPC"
+ ]
+}
diff --git a/changelog/v0.61.1.json b/changelog/v0.61.1.json
new file mode 100644
index 0000000000..f03a379cca
--- /dev/null
+++ b/changelog/v0.61.1.json
@@ -0,0 +1,17 @@
+{
+ "version": "0.61.1",
+ "date": "2026-07-28",
+ "title": "A responsive input line while turns stream",
+ "highlights": [
+ "Typing in the input line stays smooth while a turn is streaming: buffered keystrokes are coalesced into one frame and user input now beats server events to the loop"
+ ],
+ "improvements": [
+ "Remote-mode clients drain bursts of buffered terminal input (fast typing, key repeat, scroll wheels) into a single render, matching the local loop",
+ "Both interactive loops poll terminal input ahead of timers and server/bus chatter, so heavy streaming can no longer starve keystrokes",
+ "Header rebuilds no longer probe credential files on the render thread, removing periodic 40ms+ input stalls",
+ "The desktop2 preview keeps evolving: a niri-style session overview on Super, a live RAM readout, pinned live tool cards, display-paced animations, and lower per-window memory"
+ ],
+ "fixes": [
+ "Separator spacing is preserved after LaTeX symbol commands in rendered markdown"
+ ]
+}
diff --git a/changelog/v0.64.0.json b/changelog/v0.64.0.json
new file mode 100644
index 0000000000..6f0e74317e
--- /dev/null
+++ b/changelog/v0.64.0.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.64.0",
+ "date": "2026-07-30",
+ "title": "View-only clear and cross-session prompt history",
+ "highlights": [
+ "Cmd+K (or /cls) clears the chat view while the model keeps its full context, unlike /clear which starts a fresh session",
+ "Ctrl+R opens a reverse search over your prompt history across all sessions"
+ ],
+ "improvements": [
+ "/cls and /clear-view are listed in /help and command suggestions, and Cmd+Shift+K stays reserved for line scrolling"
+ ],
+ "fixes": []
+}
diff --git a/changelog/v0.64.1.json b/changelog/v0.64.1.json
new file mode 100644
index 0000000000..9072bd4e76
--- /dev/null
+++ b/changelog/v0.64.1.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.64.1",
+ "date": "2026-07-30",
+ "title": "View clear moves to Ctrl+L",
+ "highlights": [
+ "The view-only clear now lives on Ctrl+L (terminal-style) instead of Cmd+K, which collided with Cmd+J/K prompt navigation on macOS"
+ ],
+ "improvements": [
+ "Ctrl+L and /cls keep queued messages as well as context; only the rendered transcript is wiped",
+ "While a diagram or diff pane is available, Ctrl+L still focuses the pane as before"
+ ],
+ "fixes": []
+}
diff --git a/changelog/v0.64.2.json b/changelog/v0.64.2.json
new file mode 100644
index 0000000000..bfc11259c1
--- /dev/null
+++ b/changelog/v0.64.2.json
@@ -0,0 +1,15 @@
+{
+ "version": "0.64.2",
+ "date": "2026-07-30",
+ "title": "Idle animation off for everyone",
+ "highlights": [
+ "The decorative idle animation is now turned off for all users, including existing configs, via a one-time migration; re-enable it anytime with display.idle_animation = true"
+ ],
+ "improvements": [
+ "Ctrl+R reverse history search now behaves readline-style",
+ "H1/H2 markdown headings render visually larger via underline"
+ ],
+ "fixes": [
+ "Desktop: messages typed mid-turn are queued instead of being dropped with 'already processing'"
+ ]
+}
diff --git a/changelog/v0.65.0.json b/changelog/v0.65.0.json
new file mode 100644
index 0000000000..ed6c18c3c9
--- /dev/null
+++ b/changelog/v0.65.0.json
@@ -0,0 +1,34 @@
+{
+ "version": "0.65.0",
+ "date": "2026-08-02",
+ "title": "Seamless self-update, calmer TUI",
+ "highlights": [
+ "Self-update is now seamless: a live progress bar during download and a graceful in-place reload when it finishes",
+ "Todos you are working on stay pinned in a band at the top of the viewport while you scroll",
+ "Ctrl+L is a true terminal-style clear: the screen blanks, history stays above, and the prompt sits at the top"
+ ],
+ "improvements": [
+ "New display.external_sessions setting hides other CLIs' sessions from the session picker",
+ "The swarm gallery and snapshots now show which provider and auth route each agent is using",
+ "Markdown tables honour column alignment in every renderer, and copying a table gives you clean text",
+ "Inline math stays inline in image mode and blends with the surrounding prose colour",
+ "The discover_tools browse card is now a single compact line, and the sponsored-discovery notice is gone",
+ "Tool descriptions and parameter docs are capped, leaving more of the context window for your work",
+ "Ollama reports the context window it is actually serving instead of the trained window",
+ "Crash-resume hints now point at the session picker"
+ ],
+ "fixes": [
+ "Custom OpenAI-compatible profile models route to their own profile instead of Copilot",
+ "OpenAI tool catalogs no longer break on MCP tools with untyped or unsupported schema properties",
+ "Ctrl+D forward-deletes mid-edit instead of quitting",
+ "Copying on X11 uses xclip/xsel so the selection survives after jcode exits",
+ "One bad value in [display] no longer discards the rest of config.toml",
+ "Reasoning text with multi-byte characters no longer crashes the TUI",
+ "Cursor provider uses the correct regional agent host instead of hardcoding global",
+ "Anthropic reasoning effort chosen with Ctrl+O in /model now persists",
+ "launch_hotkeys enabled = false is honoured on macOS",
+ "The API key login prompt visibly responds and tells you when a key already exists",
+ "Desktop no longer shows a false 'update ready' banner on dev builds",
+ "Builds on musl and Termux/aarch64 targets are fixed"
+ ]
+}
diff --git a/changelog/v0.66.0.json b/changelog/v0.66.0.json
new file mode 100644
index 0000000000..4db098feb2
--- /dev/null
+++ b/changelog/v0.66.0.json
@@ -0,0 +1,22 @@
+{
+ "version": "0.66.0",
+ "date": "2026-08-03",
+ "title": "Build on jcode",
+ "highlights": [
+ "A production-ready TypeScript SDK and Rust SDK can now launch isolated jcode agents or connect to a running instance",
+ "SDK clients can stream turns, request validated structured output, inspect models and providers, manage session retention, search project files, and subscribe to events",
+ "The desktop app can resume stored sessions and paste clipboard images directly"
+ ],
+ "improvements": [
+ "Desktop scrolling is faster and smoother across mouse wheels, trackpads, and keyboard controls",
+ "Desktop settings expose reasoning display and copy-on-select controls",
+ "Ctrl+L now behaves like a terminal clear while keeping earlier history available above the viewport",
+ "Tool output limits prevent oversized command results from overwhelming agent context"
+ ],
+ "fixes": [
+ "Private SDK instances inherit logins safely, isolate sessions, and clean up their processes and state on close or crash",
+ "Desktop reconnects now recover their retry timing and clearly report successful reconnection",
+ "Authentication guidance no longer suggests stale static API models",
+ "Session cancellation remains correct after a session rename"
+ ]
+}
diff --git a/changelog/v0.67.0.json b/changelog/v0.67.0.json
new file mode 100644
index 0000000000..67f31f2430
--- /dev/null
+++ b/changelog/v0.67.0.json
@@ -0,0 +1,21 @@
+{
+ "version": "0.67.0",
+ "date": "2026-08-03",
+ "title": "Richer SDK and desktop workflows",
+ "highlights": [
+ "The Rust SDK now supports owned launches, global lifecycle events, schema-validated structured runs, and runtime file management",
+ "The new desktop experience adds multi-session workspace navigation, native math typesetting, persistent plans, richer settings and model selection, and image previews",
+ "Headed terminal spawns now integrate with Herdr for reliable visible agent sessions"
+ ],
+ "improvements": [
+ "Todo plans now use semantic quality assessments and continue iterating when evidence shows meaningful work remains",
+ "Rust and TypeScript SDK capabilities are kept in parity with clearer lifecycle behavior",
+ "Desktop self-development builds can relaunch registered desktop instances automatically"
+ ],
+ "fixes": [
+ "Restored desktop edit cards preserve their file names",
+ "Desktop errors render as distinct red cards",
+ "Todo assessment changes and low-confidence completion notices render correctly in the terminal",
+ "Activating a Jcode account now refreshes available models automatically"
+ ]
+}
diff --git a/changelog/v0.67.1.json b/changelog/v0.67.1.json
new file mode 100644
index 0000000000..d8283539f1
--- /dev/null
+++ b/changelog/v0.67.1.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.67.1",
+ "date": "2026-08-03",
+ "title": "Provider reliability fixes",
+ "improvements": [
+ "Anthropic usage now shows model-specific weekly limits alongside account-wide windows"
+ ],
+ "fixes": [
+ "Gemini tool schemas are sanitized for provider compatibility",
+ "MCP notifications no longer cause request-handling failures",
+ "Completed desktop todo items render without a duplicate marker glyph"
+ ]
+}
diff --git a/changelog/v0.68.0.json b/changelog/v0.68.0.json
new file mode 100644
index 0000000000..17d999902e
--- /dev/null
+++ b/changelog/v0.68.0.json
@@ -0,0 +1,26 @@
+{
+ "version": "0.68.0",
+ "date": "2026-08-05",
+ "title": "Smarter provider compatibility and cloud onboarding",
+ "highlights": [
+ "Tool schemas now adapt to each provider automatically and recover from newly reported schema incompatibilities",
+ "Managed cloud activation is now the default remote setup flow",
+ "The TypeScript SDK now bundles platform-specific jcode runtimes for easier installation"
+ ],
+ "improvements": [
+ "Integration discovery can record intentional off-catalog selections",
+ "Client hooks can now be composed",
+ "Todo cards remain readable at narrow window widths",
+ "LaTeX rendering uses consistent high-contrast colors"
+ ],
+ "fixes": [
+ "Claude MCP configuration now stays live, loads project settings from the working directory, and expands environment variables",
+ "Provider credentials are no longer inherited by MCP servers unless configured explicitly",
+ "ACP resumes subscribe before attaching so early events are not missed",
+ "Favorite-model cycling now visits every configured favorite",
+ "Linux power inhibition no longer loops on authorization failures",
+ "Desktop session previews no longer interfere with live turns, and overview scrolling stops cleanly",
+ "macOS notification clicks now route to the correct session",
+ "Anthropic quota failures reroute to an available fallback model"
+ ]
+}
diff --git a/changelog/v0.69.0.json b/changelog/v0.69.0.json
new file mode 100644
index 0000000000..5b6f502083
--- /dev/null
+++ b/changelog/v0.69.0.json
@@ -0,0 +1,27 @@
+{
+ "version": "0.69.0",
+ "date": "2026-08-06",
+ "title": "ACP controls and smoother hosted access",
+ "highlights": [
+ "ACP clients can now select models and reasoning effort, receive token usage, and update session configuration",
+ "Hosted subscriptions now use metered billing with a simpler onboarding experience",
+ "macOS turn notifications now use a dedicated broker for more reliable delivery and session focus"
+ ],
+ "improvements": [
+ "You can replace jcode's base system prompt with .jcode/system-prompt.md",
+ "Composer Up and Down navigation now follows visually wrapped rows",
+ "Keybinding changes take effect on the next keystroke without restarting",
+ "Config file edits now explain what changed and whether a restart is required",
+ "Client-scoped hooks can now run multiple commands",
+ "Ctrl+L now provides a true terminal-style clear while preserving scrollback"
+ ],
+ "fixes": [
+ "Explicitly resumed TUI sessions now replay their history",
+ "Text-only provider requests no longer include unsupported image content",
+ "Duplicate tool results can no longer wedge a session",
+ "Gemini and Antigravity credentials no longer appear expired when refresh succeeds",
+ "Todo auto-poke cycles avoid repeated unchanged prompts and resume correctly",
+ "Model and provider pickers no longer show duplicate compatibility or discovery labels",
+ "OpenAI-compatible MCP tools accept untyped object properties without breaking the tool catalog"
+ ]
+}
diff --git a/changelog/v0.70.0.json b/changelog/v0.70.0.json
new file mode 100644
index 0000000000..b7510e068f
--- /dev/null
+++ b/changelog/v0.70.0.json
@@ -0,0 +1,18 @@
+{
+ "version": "0.70.0",
+ "date": "2026-08-06",
+ "title": "Terminal-aware forks and an inline model picker",
+ "highlights": [
+ "Forked sessions now follow your active terminal environment, opening Ghostty tabs, tmux or Zellij panes, GNU Screen windows, and appropriate platform terminals",
+ "The desktop model picker now opens as an animated inline transcript chooser with keyboard navigation"
+ ],
+ "improvements": [
+ "Client-scoped hooks can run multiple commands while preserving the requesting terminal context",
+ "Tool-heavy turns receive clearer guidance to batch independent operations"
+ ],
+ "fixes": [
+ "Ghostty forks fall back to a new window when macOS tab automation is unavailable instead of silently disappearing",
+ "Synthetic provider-recovery instructions no longer appear as user prompts in transcripts",
+ "Todo follow-up checks now use more targeted and neutral prompts"
+ ]
+}
diff --git a/changelog/v0.70.1.json b/changelog/v0.70.1.json
new file mode 100644
index 0000000000..9cc216451f
--- /dev/null
+++ b/changelog/v0.70.1.json
@@ -0,0 +1,8 @@
+{
+ "version": "0.70.1",
+ "date": "2026-08-06",
+ "title": "Release validation follow-up",
+ "improvements": [
+ "The inline desktop model picker release now passes repository formatting validation"
+ ]
+}
diff --git a/changelog/v0.71.0.json b/changelog/v0.71.0.json
new file mode 100644
index 0000000000..ca9321535a
--- /dev/null
+++ b/changelog/v0.71.0.json
@@ -0,0 +1,20 @@
+{
+ "version": "0.71.0",
+ "date": "2026-08-06",
+ "title": "Subscription onboarding and broader model support",
+ "highlights": [
+ "Onboarding now defaults to the Jcode subscription, with clearer hosted-model pricing and a direct path to subscription details",
+ "OpenAI users can choose the full GPT-5.6 family, including Terra, and Meta Model API users can configure Muse and DeepSeek passback models"
+ ],
+ "improvements": [
+ "Todo quality feedback now distinguishes synthetic validation and tracks requirement-to-check traceability",
+ "Cargo commands report action durations to make slow build and test steps easier to identify"
+ ],
+ "fixes": [
+ "Scheduled turns no longer leak into user prompt history",
+ "Empty transcript checkpoints are prevented so session history remains usable",
+ "Terminal launches fall back cleanly when spawn hooks reject a launch",
+ "The TypeScript SDK waits for daemon registration before closing launched sessions",
+ "Permanent memory sidecar failures are distinguished from transient failures and use the current Claude model"
+ ]
+}
diff --git a/changelog/v0.71.1.json b/changelog/v0.71.1.json
new file mode 100644
index 0000000000..ba66a9e124
--- /dev/null
+++ b/changelog/v0.71.1.json
@@ -0,0 +1,16 @@
+{
+ "version": "0.71.1",
+ "date": "2026-08-08",
+ "title": "TypeScript SDK reliability and runtime refresh",
+ "highlights": [
+ "The TypeScript SDK now ships current jcode runtimes instead of the v0.67.1 runtime",
+ "Launched SDK sessions shut down reliably even when daemon registration is delayed"
+ ],
+ "improvements": [
+ "Platform runtime packages are refreshed together at SDK version 1.2.0"
+ ],
+ "fixes": [
+ "Linux ARM64 release builds use the platform-native C character type for terminal lookup",
+ "Anthropic OAuth sessions use a runtime that tolerates unavailable usage reporting while preserving inference"
+ ]
+}
diff --git a/changelog/v0.73.0.json b/changelog/v0.73.0.json
new file mode 100644
index 0000000000..456b4eef8d
--- /dev/null
+++ b/changelog/v0.73.0.json
@@ -0,0 +1,19 @@
+{
+ "version": "0.73.0",
+ "date": "2026-08-09",
+ "title": "Focused desktop sessions and provider reliability",
+ "highlights": [
+ "Desktop session panels can expand the focused session for more working space"
+ ],
+ "improvements": [
+ "Switching models preserves the active catalog profile",
+ "Tab focus changes avoid unnecessary full terminal repaints",
+ "Missing swarm server errors now explain how to restore the connection"
+ ],
+ "fixes": [
+ "OpenAI model responses no longer duplicate thinking text",
+ "GitHub Copilot tool schemas are normalized for reliable tool calls",
+ "Custom models default to text input unless they explicitly advertise other capabilities",
+ "Homebrew launchers preserve command-line arguments during startup"
+ ]
+}
diff --git a/changelog/v0.74.0.json b/changelog/v0.74.0.json
new file mode 100644
index 0000000000..03ddb27122
--- /dev/null
+++ b/changelog/v0.74.0.json
@@ -0,0 +1,24 @@
+{
+ "version": "0.74.0",
+ "date": "2026-08-10",
+ "title": "Better ACP controls and focused session workflows",
+ "highlights": [
+ "ACP clients can switch models and reasoning effort, list available models, and use model control slash commands",
+ "Jcode can search version-matched documentation bundled with the installed build",
+ "The session picker can filter sessions to the current working directory"
+ ],
+ "improvements": [
+ "Session todos are pinned by default without being duplicated in the transcript or info widgets",
+ "Config and swarm prompt editors now temporarily hand off terminal control and restore the TUI after exit",
+ "Independent root sessions in the same repository now keep separate swarm plans while spawned workers remain attached to their parent swarm"
+ ],
+ "fixes": [
+ "Headed forks reliably submit their staged startup prompt after subscribing",
+ "ACP tool policies that enable MCP now include tools dynamically exposed by configured MCP servers",
+ "Desktop new-session transitions no longer stall while establishing a live connection",
+ "Explicit OpenRouter provider selections remain pinned",
+ "Nested shell commands and concrete external paths receive more accurate command-risk classification",
+ "Imitated Antigravity tool calls are rejected instead of being treated as genuine tool requests",
+ "Ollama cloud models use their reported context metadata"
+ ]
+}
diff --git a/changelog/v0.75.0.json b/changelog/v0.75.0.json
new file mode 100644
index 0000000000..0b798309e3
--- /dev/null
+++ b/changelog/v0.75.0.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.75.0",
+ "date": "2026-08-10",
+ "title": "A more capable desktop workspace",
+ "highlights": [
+ "The desktop app now includes a project file explorer and an in-app help overlay",
+ "Desktop sessions open as spatial panels with familiar new-tab shortcuts and animated navigation",
+ "The desktop window can hot-reload app code without being recreated"
+ ],
+ "improvements": [
+ "Desktop reasoning now shows the full thought history by default",
+ "Desktop resume navigation supports Vim-style keyboard chords",
+ "Desktop activity appears immediately while a response is starting"
+ ],
+ "fixes": [
+ "Desktop windows remain visible and reconnect cleanly across daemon and app reloads",
+ "Desktop session polling no longer interferes with live requests",
+ "Adjacent diff rows no longer show hairline gaps at fractional display scales",
+ "Explicit provider routes remain pinned when selected",
+ "Shifted punctuation and meta new-session shortcuts work consistently in the TUI",
+ "ACP clients enforce dynamic MCP tool policy"
+ ]
+}
diff --git a/changelog/v0.75.1.json b/changelog/v0.75.1.json
new file mode 100644
index 0000000000..dcd281bba9
--- /dev/null
+++ b/changelog/v0.75.1.json
@@ -0,0 +1,18 @@
+{
+ "version": "0.75.1",
+ "date": "2026-08-11",
+ "title": "More reliable autonomous runs",
+ "highlights": [
+ "Todo completion synonyms such as done and finished no longer trigger false auto-poke loops",
+ "Grok Build is available as an ACP provider"
+ ],
+ "improvements": [
+ "Todo status values are documented and constrained in the tool schema",
+ "Desktop and SDK clients expose provider request lifecycle status"
+ ],
+ "fixes": [
+ "Todo statuses are normalized on write and compared case-insensitively during headless run completion checks",
+ "Codex quota windows no longer appear more than once",
+ "Pinned todo configuration tests no longer leak process-global configuration"
+ ]
+}
diff --git a/changelog/v0.75.2.json b/changelog/v0.75.2.json
new file mode 100644
index 0000000000..d49ab7cd7d
--- /dev/null
+++ b/changelog/v0.75.2.json
@@ -0,0 +1,14 @@
+{
+ "version": "0.75.2",
+ "date": "2026-08-11",
+ "title": "Strict todo status validation",
+ "highlights": [
+ "The todo tool now rejects unknown status values instead of storing them silently"
+ ],
+ "improvements": [
+ "Invalid status errors list the accepted pending, in_progress, completed, and cancelled values"
+ ],
+ "fixes": [
+ "Unknown model-written status strings can no longer leave todo completion behavior ambiguous"
+ ]
+}
diff --git a/changelog/v0.75.3.json b/changelog/v0.75.3.json
new file mode 100644
index 0000000000..c51f859f40
--- /dev/null
+++ b/changelog/v0.75.3.json
@@ -0,0 +1,14 @@
+{
+ "version": "0.75.3",
+ "date": "2026-08-11",
+ "title": "Reliable streams and desktop session overview",
+ "highlights": [
+ "Desktop sessions now include a restored Super-key overview with clickable session navigation"
+ ],
+ "improvements": [
+ "The desktop overview shortcut works safely across compositors"
+ ],
+ "fixes": [
+ "Transient stream_read_error failures are now recognized as retryable transport errors"
+ ]
+}
diff --git a/changelog/v0.75.4.json b/changelog/v0.75.4.json
new file mode 100644
index 0000000000..80b2d772f2
--- /dev/null
+++ b/changelog/v0.75.4.json
@@ -0,0 +1,19 @@
+{
+ "version": "0.75.4",
+ "date": "2026-08-12",
+ "title": "More reliable remote sessions, providers, and automation",
+ "highlights": [
+ "Remote sessions now preserve active skills and recover cleanly after clearing session state",
+ "OpenAI-compatible and OpenRouter streams handle transient failures and fallback tool calls more reliably"
+ ],
+ "improvements": [
+ "ACP sessions tolerate host-provided MCP servers and report turn token usage",
+ "Shifted keyboard symbols report their alternate keys consistently",
+ "Development builds choose safer job counts based on available memory"
+ ],
+ "fixes": [
+ "Background commands no longer inherit stdin unexpectedly",
+ "Telemetry respects opt-out before install events and isolates tests from user configuration",
+ "Fallback tool-call identifiers remain unique when providers omit IDs"
+ ]
+}
diff --git a/changelog/v0.75.5.json b/changelog/v0.75.5.json
new file mode 100644
index 0000000000..4985d49946
--- /dev/null
+++ b/changelog/v0.75.5.json
@@ -0,0 +1,20 @@
+{
+ "version": "0.75.5",
+ "date": "2026-08-12",
+ "title": "Reliable remote sessions, providers, and release builds",
+ "highlights": [
+ "Remote sessions now preserve active skills and recover cleanly after clearing session state",
+ "OpenAI-compatible and OpenRouter streams handle transient failures and fallback tool calls more reliably"
+ ],
+ "improvements": [
+ "ACP sessions tolerate host-provided MCP servers and report turn token usage",
+ "Shifted keyboard symbols report their alternate keys consistently",
+ "Development builds choose safer job counts based on available memory"
+ ],
+ "fixes": [
+ "Background commands no longer inherit stdin unexpectedly",
+ "Telemetry respects opt-out before install events and isolates tests from user configuration",
+ "Fallback tool-call identifiers remain unique when providers omit IDs",
+ "ACP prompt requests now compile correctly in optimized cross-platform release builds"
+ ]
+}
diff --git a/changelog/v0.76.0.json b/changelog/v0.76.0.json
new file mode 100644
index 0000000000..6eddfa64dd
--- /dev/null
+++ b/changelog/v0.76.0.json
@@ -0,0 +1,22 @@
+{
+ "version": "0.76.0",
+ "date": "2026-08-14",
+ "title": "Transcript privacy and broader provider support",
+ "highlights": [
+ "Opt-in transcript telemetry now supports privacy-preserving collection with automatic secret redaction",
+ "Anthropic-compatible provider profiles can now connect to additional compatible services",
+ "Grok Build login can now be completed directly inside the TUI"
+ ],
+ "improvements": [
+ "Startup update checks can now be disabled in configuration",
+ "Transient provider retries are more resilient and configurable",
+ "Z.AI Coding Plan supports reasoning effort controls and safely handles text-only models"
+ ],
+ "fixes": [
+ "OpenRouter responses preserve tool outputs even when the corresponding tool call is unavailable",
+ "Prompt files are deduplicated and oversized skill context is clipped",
+ "Repeated paste placeholders expand correctly",
+ "Self-development builds promote artifacts to the correct paths",
+ "Grok model lists refresh after login and use the current managed backend"
+ ]
+}
diff --git a/changelog/v0.77.0.json b/changelog/v0.77.0.json
new file mode 100644
index 0000000000..dbeabd6ff7
--- /dev/null
+++ b/changelog/v0.77.0.json
@@ -0,0 +1,26 @@
+{
+ "version": "0.77.0",
+ "date": "2026-08-17",
+ "title": "Background visibility and expanded authentication",
+ "highlights": [
+ "Background tasks now appear in the pinned status band, report intermediate progress reliably, and wake stalled agents automatically",
+ "Cursor and Grok Build now support native authentication flows, with OrcaRouter available as a provider profile",
+ "Duplicate provider accounts now receive memorable animal names in the account picker"
+ ],
+ "improvements": [
+ "Todo intent and understanding are shown inline with clearer status colors and expandable pinned details",
+ "Pinned todo, copy, and edit-expand controls can now be activated with the mouse",
+ "Session search finds late transcript content and preserves longer search prefixes",
+ "The SDK now exposes persisted session titles and reasoning-effort updates",
+ "Bash output is collapsed by default with a cleaner presentation",
+ "Users can submit privacy-conscious maintainer feedback after explicit consent"
+ ],
+ "fixes": [
+ "Pasted content remains visible after sending",
+ "Hyphenated MCP tool names dispatch correctly",
+ "Repeated OpenRouter tool outputs are preserved",
+ "Provider selection honors an explicitly requested CLI provider",
+ "Setup hotkey uninstall actions are honored",
+ "macOS swarm Option shortcuts work correctly"
+ ]
+}
diff --git a/changelog/v0.77.1.json b/changelog/v0.77.1.json
new file mode 100644
index 0000000000..6a6743ff96
--- /dev/null
+++ b/changelog/v0.77.1.json
@@ -0,0 +1,7 @@
+{
+ "version": "0.77.1",
+ "date": "2026-08-17",
+ "fixes": [
+ "The remote release command now detects the current repository and follows its own release conventions instead of assuming Jcode-specific tooling"
+ ]
+}
diff --git a/changelog/v0.77.2.json b/changelog/v0.77.2.json
new file mode 100644
index 0000000000..cd51675abc
--- /dev/null
+++ b/changelog/v0.77.2.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.77.2",
+ "date": "2026-08-18",
+ "improvements": [
+ "Inline diff previews now show the affected file path for clearer review context"
+ ],
+ "fixes": [
+ "One-shot sessions now close automatically after completing their response",
+ "Ambient launches now fall back gracefully when opening a visible terminal fails",
+ "Spawned agents keep their prompt when an explicitly blank initial message is supplied",
+ "MiniMax authentication now uses the correct API key variable while preserving existing credentials"
+ ]
+}
diff --git a/changelog/v0.78.0.json b/changelog/v0.78.0.json
new file mode 100644
index 0000000000..05e7b5a9fa
--- /dev/null
+++ b/changelog/v0.78.0.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.78.0",
+ "date": "2026-08-18",
+ "highlights": [
+ "Harness API and SDK clients can now receive images embedded in transcript messages"
+ ],
+ "improvements": [
+ "Todo quality checks now give shorter, clearer guidance and avoid repeatedly blocking final responses"
+ ],
+ "fixes": [
+ "Completed hook observers are now cleaned up reliably instead of accumulating over time"
+ ]
+}
diff --git a/changelog/v0.78.1.json b/changelog/v0.78.1.json
new file mode 100644
index 0000000000..a211052ba2
--- /dev/null
+++ b/changelog/v0.78.1.json
@@ -0,0 +1,16 @@
+{
+ "version": "0.78.1",
+ "date": "2026-08-19",
+ "highlights": [
+ "Git integrations can now be discovered through the tool catalog"
+ ],
+ "improvements": [
+ "Session discovery is faster and bounded for dashboard clients"
+ ],
+ "fixes": [
+ "Pinned background tasks now stay limited to the two most recently active tasks",
+ "Station sessions now use the correct model status routing",
+ "Anthropic catalog probes now send the required provider headers",
+ "Command safety checks no longer flag inert heredoc payloads"
+ ]
+}
diff --git a/changelog/v0.79.0.json b/changelog/v0.79.0.json
new file mode 100644
index 0000000000..2de6db319d
--- /dev/null
+++ b/changelog/v0.79.0.json
@@ -0,0 +1,16 @@
+{
+ "version": "0.79.0",
+ "date": "2026-08-21",
+ "highlights": [
+ "Large MCP tool catalogs can now be deferred automatically to keep agent context compact",
+ "Recent session lists now use a durable metadata index for faster dashboard loading"
+ ],
+ "improvements": [
+ "Background commands promoted after a timeout now report live progress"
+ ],
+ "fixes": [
+ "Remote TUI sessions now dispatch productivity results and handle Alt-key toggles correctly",
+ "Memory sidecars now preserve provider routing while omitting unnecessary metadata",
+ "Invalid CI telemetry overrides now fall back safely"
+ ]
+}
diff --git a/changelog/v0.79.1.json b/changelog/v0.79.1.json
new file mode 100644
index 0000000000..456aed19e8
--- /dev/null
+++ b/changelog/v0.79.1.json
@@ -0,0 +1,16 @@
+{
+ "version": "0.79.1",
+ "date": "2026-08-21",
+ "highlights": [
+ "Large MCP tool catalogs can now be deferred automatically to keep agent context compact",
+ "Recent session lists now use a durable metadata index for faster dashboard loading"
+ ],
+ "improvements": [
+ "Background commands promoted after a timeout now report live progress"
+ ],
+ "fixes": [
+ "Remote TUI sessions now dispatch productivity results and handle Alt-key toggles correctly",
+ "Memory sidecars now preserve provider routing while omitting unnecessary metadata",
+ "Invalid CI telemetry overrides now fall back safely"
+ ]
+}
diff --git a/changelog/v0.80.0.json b/changelog/v0.80.0.json
new file mode 100644
index 0000000000..878cbb3b0e
--- /dev/null
+++ b/changelog/v0.80.0.json
@@ -0,0 +1,20 @@
+{
+ "version": "0.80.0",
+ "date": "2026-08-24",
+ "highlights": [
+ "Integration discovery can now investigate a candidate's capabilities, compatibility, setup, pricing, and limitations before selecting it",
+ "Subagent models can now be chosen from the full model picker and stay synchronized across remote sessions",
+ "Model thinking is now shown by default"
+ ],
+ "improvements": [
+ "Onboarding offers clearer project choices and supports rotating choices from any key",
+ "Gmail replies now preserve thread context from message IDs and correctly encode non-ASCII subjects",
+ "Session context now reports the local date, time, and timezone",
+ "The todo side panel is more compact"
+ ],
+ "fixes": [
+ "Subagent model selection is preserved when the model catalog refreshes",
+ "Configured default models are preserved after authentication",
+ "OpenAI reasoning replay no longer sends output-only status fields"
+ ]
+}
diff --git a/changelog/v0.80.1.json b/changelog/v0.80.1.json
new file mode 100644
index 0000000000..78ce978b6c
--- /dev/null
+++ b/changelog/v0.80.1.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.80.1",
+ "date": "2026-08-25",
+ "highlights": [],
+ "improvements": [
+ "Custom OpenAI-compatible providers can configure reasoning capability and default effort per model"
+ ],
+ "fixes": [
+ "Custom gateways can disable model-name reasoning heuristics that caused unsupported reasoning_effort parameters to be sent"
+ ]
+}
diff --git a/changelog/v0.81.0.json b/changelog/v0.81.0.json
new file mode 100644
index 0000000000..54df08d870
--- /dev/null
+++ b/changelog/v0.81.0.json
@@ -0,0 +1,18 @@
+{
+ "version": "0.81.0",
+ "date": "2026-08-25",
+ "title": "Embedder control",
+ "highlights": [
+ "Headless embedders can use external wake mode to receive typed wake requests without the daemon starting invisible turns",
+ "Operators can pin one model and authentication route for every spawned swarm worker"
+ ],
+ "improvements": [
+ "The Rust and TypeScript SDKs can launch isolated jcode runtimes on Windows and configure swarm models and wake behavior",
+ "The SDK now launches private API bridges through the supported jcode CLI entry point",
+ "Remote model pickers use the daemon-provided route catalog without unnecessary network refreshes"
+ ],
+ "fixes": [
+ "Invalid configuration files are preserved when settings change instead of being overwritten",
+ "Mistral reasoning effort is normalized to the supported maximum"
+ ]
+}
diff --git a/changelog/v0.81.1.json b/changelog/v0.81.1.json
new file mode 100644
index 0000000000..122e1525be
--- /dev/null
+++ b/changelog/v0.81.1.json
@@ -0,0 +1,7 @@
+{
+ "version": "0.81.1",
+ "date": "2026-08-25",
+ "fixes": [
+ "Desktop-owned sessions are marked as crashed when their client disappears unexpectedly, while deliberate detaches remain clean"
+ ]
+}
diff --git a/changelog/v0.81.2.json b/changelog/v0.81.2.json
new file mode 100644
index 0000000000..226be54405
--- /dev/null
+++ b/changelog/v0.81.2.json
@@ -0,0 +1,9 @@
+{
+ "version": "0.81.2",
+ "date": "2026-08-28",
+ "fixes": [
+ "Inline Mermaid diagrams and raster images now cycle only through visibly distinct sizes when clicked",
+ "Resized Mermaid diagrams invalidate cached message geometry and no longer leave blank placeholder space underneath",
+ "Clicking an inline image copies its pixels, while clicking a Mermaid diagram copies its editable source code"
+ ]
+}
diff --git a/changelog/v0.81.3.json b/changelog/v0.81.3.json
new file mode 100644
index 0000000000..fdafc219a5
--- /dev/null
+++ b/changelog/v0.81.3.json
@@ -0,0 +1,12 @@
+{
+ "version": "0.81.3",
+ "date": "2026-08-29",
+ "improvements": [
+ "Active tool details are now emphasized in the running status line for faster progress scanning",
+ "Selected text remains clearly visible while copying from the terminal"
+ ],
+ "fixes": [
+ "Session attachments are correlated to the correct connection when multiple clients connect concurrently",
+ "Closing a macOS terminal window no longer leaves its jcode session running unexpectedly"
+ ]
+}
diff --git a/changelog/v0.81.4.json b/changelog/v0.81.4.json
new file mode 100644
index 0000000000..29dbdac819
--- /dev/null
+++ b/changelog/v0.81.4.json
@@ -0,0 +1,7 @@
+{
+ "version": "0.81.4",
+ "date": "2026-08-30",
+ "improvements": [
+ "Internal reliability and performance work"
+ ]
+}
diff --git a/changelog/v0.81.5.json b/changelog/v0.81.5.json
new file mode 100644
index 0000000000..89b9f820e0
--- /dev/null
+++ b/changelog/v0.81.5.json
@@ -0,0 +1,19 @@
+{
+ "version": "0.81.5",
+ "date": "2026-09-03",
+ "features": [
+ "Add claude-fable-5-1 to the direct Anthropic catalog",
+ "Add agents.swarm_effort config pin and JCODE_SWARM_EFFORT override; show worker effort in swarm list"
+ ],
+ "fixes": [
+ "Persist sessions created with a title before the first visible message",
+ "Hold the idle-agent reservation through the wake turn and terminal status fanout",
+ "Price OpenRouter @endpoint-pinned models from the pinned endpoint",
+ "Accept disabled failover aliases",
+ "Clear inline images when the session is cleared",
+ "Isolate sandboxed homes from the macOS Keychain",
+ "Preserve macOS Ctrl+5 prompt jump",
+ "Update Claude OAuth client version",
+ "Fail fast on hard usage-limit exhaustion in auth-test instead of retrying"
+ ]
+}
diff --git a/changelog/v0.81.6.json b/changelog/v0.81.6.json
new file mode 100644
index 0000000000..ee9baa2fa3
--- /dev/null
+++ b/changelog/v0.81.6.json
@@ -0,0 +1,8 @@
+{
+ "version": "0.81.6",
+ "date": "2026-09-03",
+ "features": [],
+ "fixes": [
+ "Send the x-opencode-session header on OpenCode Go/Zen requests (required from 2026-09-05)"
+ ]
+}
diff --git a/changelog/v0.81.7.json b/changelog/v0.81.7.json
new file mode 100644
index 0000000000..0514007049
--- /dev/null
+++ b/changelog/v0.81.7.json
@@ -0,0 +1,9 @@
+{
+ "version": "0.81.7",
+ "date": "2026-09-04",
+ "features": [
+ "Add GPT-6 Astra (gpt-6-astra) to the OpenAI model catalog and make it the default OpenAI model",
+ "Carry attached images into /fork and /btw prompts"
+ ],
+ "fixes": []
+}
diff --git a/changelog/v0.82.0.json b/changelog/v0.82.0.json
new file mode 100644
index 0000000000..e140dd088a
--- /dev/null
+++ b/changelog/v0.82.0.json
@@ -0,0 +1,24 @@
+{
+ "version": "0.82.0",
+ "date": "2026-09-06",
+ "title": "Faster starts and richer session controls",
+ "highlights": [
+ "OpenAI Responses WebSocket connections now warm up while sessions are idle and before request preparation, reducing time to the first response",
+ "Click images in the side panel to open an enlarged preview",
+ "Rust and TypeScript SDKs can include image attachments in soft interrupts, and interrupts sent to idle sessions now start a response immediately"
+ ],
+ "improvements": [
+ "Swarm workers can use an explicit model override or inherit the coordinator model",
+ "Configure MCP request timeouts separately for each server with timeout_secs",
+ "Headless and browser-suppressed login flows display a QR code for signing in on another device"
+ ],
+ "fixes": [
+ "Reattaching to a session preserves its saved working directory",
+ "Fast service-tier selections remain visible after reconnecting or refreshing the model catalog",
+ "Mouse-wheel scrolling works on the hovered diagram without first moving keyboard focus",
+ "Idle session disconnects are no longer incorrectly marked as crashes",
+ "Repeated ownership completion checks no longer interrupt unchanged sessions",
+ "Remote turns report actual context compaction metrics",
+ "Reduce retained memory from session tool policies, batch tools, and MCP registries"
+ ]
+}
diff --git a/changelog/v0.83.0.json b/changelog/v0.83.0.json
new file mode 100644
index 0000000000..9dd2c639b7
--- /dev/null
+++ b/changelog/v0.83.0.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.83.0",
+ "date": "2026-09-06",
+ "title": "Native SSH sessions and remote login",
+ "highlights": [
+ "Run the local terminal UI against a remote Jcode server over SSH, keeping the workspace, tools, and agent execution on the remote host",
+ "Explicitly import a local Jcode-managed OpenAI or Claude login to a trusted SSH host after confirmation, without overwriting an existing remote credential store",
+ "Sign in to Novita with its built-in API-key login option"
+ ],
+ "improvements": [
+ "Authenticate supported providers on an SSH host from the local TUI with private login prompts and per-attempt cancellation",
+ "Native SSH sessions opt in to continuing active turns after a connection drops",
+ "Rust SDK clients can connect to native harnesses through system SSH"
+ ],
+ "fixes": [
+ "Attaching to unsaved live sessions uses the server's working directory, and brief client reloads preserve idle unsaved sessions",
+ "Forking an empty live session works and preserves the linked child session",
+ "Model catalogs retain reasoning effort and clear stale model or provider settings when sessions and providers change",
+ "SDK session event streams receive runtime details, route availability updates, and connection phases for the correct session",
+ "Session creation no longer waits for superseded telemetry work",
+ "Desktop session-launch shortcuts are forwarded correctly under niri"
+ ]
+}
diff --git a/changelog/v0.84.0.json b/changelog/v0.84.0.json
new file mode 100644
index 0000000000..5bfe82dbec
--- /dev/null
+++ b/changelog/v0.84.0.json
@@ -0,0 +1,16 @@
+{
+ "version": "0.84.0",
+ "date": "2026-09-06",
+ "title": "Clearer remote login onboarding",
+ "highlights": [
+ "Connecting to an SSH host now asks a plain yes-or-no question before offering to import a local OpenAI or Claude login"
+ ],
+ "improvements": [
+ "The remote login picker lists the providers the host actually supports, with local imports shown as explicit choices",
+ "Remote login status is sanitized before display, so host credential details are never echoed back"
+ ],
+ "fixes": [
+ "Pasted input while choosing a login stays in the active picker instead of leaking into the prompt",
+ "Remote hosts configured with an unrecognized provider are reported as unknown rather than signed in"
+ ]
+}
diff --git a/crates/jcode-agent-runtime/src/lib.rs b/crates/jcode-agent-runtime/src/lib.rs
index be70183e94..991536d70d 100644
--- a/crates/jcode-agent-runtime/src/lib.rs
+++ b/crates/jcode-agent-runtime/src/lib.rs
@@ -4,6 +4,7 @@ use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct SoftInterruptMessage {
pub content: String,
+ pub images: Vec<(String, String)>,
/// If true, can skip remaining tools when injected at point C.
pub urgent: bool,
pub source: SoftInterruptSource,
diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml
index 4dbe659440..d0bdf12b7d 100644
--- a/crates/jcode-app-core/Cargo.toml
+++ b/crates/jcode-app-core/Cargo.toml
@@ -61,6 +61,7 @@ jcode-agent-runtime = { path = "../jcode-agent-runtime" }
jcode-ambient-types = { path = "../jcode-ambient-types" }
jcode-notify-email = { path = "../jcode-notify-email" }
jcode-provider-core = { path = "../jcode-provider-core" }
+jcode-schema-dialect = { path = "../jcode-schema-dialect" }
# NOTE: jcode-app-core does NOT depend on any jcode-tui-* crate. They were
# unused dead dependency edges here (the TUI declares them itself). Removing
# them stops a jcode-tui-* edit from cascading a recompile through app-core.
@@ -86,6 +87,7 @@ jcode-build-meta = { path = "../jcode-build-meta" }
# Re-exported via `pub use jcode_base::*` in lib.rs. default-features=false so
# this crate controls jcode-base's optional features (see [features] below).
jcode-base = { path = "../jcode-base", default-features = false }
+jcode-command-risk = { path = "../jcode-command-risk" }
jcode-core = { path = "../jcode-core" }
jcode-message-types = { path = "../jcode-message-types" }
jcode-overnight-core = { path = "../jcode-overnight-core" }
@@ -113,6 +115,8 @@ jemalloc-prof = ["jcode-base/jemalloc-prof"]
embeddings = ["jcode-base/embeddings"]
# live AWS Bedrock support lives in jcode-base (provider/bedrock); forward there.
bedrock = ["jcode-base/bedrock"]
+# Used only by the CentOS 7 portable Linux release build.
+linux-compat-vendored-openssl = ["jcode-notify-email/linux-compat-vendored-openssl"]
# PDF text extraction lives in this crate (tool/read.rs).
pdf = ["dep:jcode-pdf"]
# Compiles this crate's test-only helpers/accessors (e.g. the
diff --git a/crates/jcode-app-core/build.rs b/crates/jcode-app-core/build.rs
new file mode 100644
index 0000000000..7b208a112d
--- /dev/null
+++ b/crates/jcode-app-core/build.rs
@@ -0,0 +1,44 @@
+use std::env;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+fn main() {
+ let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
+ let repo = manifest.join("../..");
+ let docs_dir = repo.join("docs");
+ println!(
+ "cargo:rerun-if-changed={}",
+ repo.join("README.md").display()
+ );
+ println!("cargo:rerun-if-changed={}", docs_dir.display());
+
+ let mut files = vec![repo.join("README.md")];
+ if let Ok(entries) = fs::read_dir(&docs_dir) {
+ files.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
+ path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md")
+ }));
+ }
+ files.sort();
+
+ let mut generated = String::from("pub(crate) static JCODE_DOCS: &[(&str, &str)] = &[\n");
+ for path in files {
+ let relative = path
+ .strip_prefix(&repo)
+ .expect("documentation is in repository");
+ let relative = slash_path(relative);
+ generated.push_str(&format!(
+ " ({relative:?}, include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../{relative}\"))),\n"
+ ));
+ }
+ generated.push_str("];\n");
+
+ let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")).join("jcode_docs.rs");
+ fs::write(out, generated).expect("write generated Jcode documentation corpus");
+}
+
+fn slash_path(path: &Path) -> String {
+ path.components()
+ .map(|part| part.as_os_str().to_string_lossy())
+ .collect::>()
+ .join("/")
+}
diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs
index 70eb9a1061..6da8bb1ddb 100644
--- a/crates/jcode-app-core/src/agent.rs
+++ b/crates/jcode-app-core/src/agent.rs
@@ -5,6 +5,8 @@ mod environment;
mod inline_tail;
mod interrupts;
mod messages;
+#[cfg(test)]
+mod model_usage_tests;
mod prompting;
mod provider;
mod response_recovery;
@@ -186,6 +188,12 @@ pub struct Agent {
active_skill: Option,
allowed_tools: Option>,
disabled_tools: HashSet,
+ /// Generation-scoped ownership of this Agent's global tool-policy entry.
+ _tool_policy_registration: crate::tool::SessionToolPolicyRegistration,
+ /// MCP top-level definition exposure policy captured when the session starts.
+ mcp_tools_mode: crate::config::McpToolsMode,
+ /// Auto-mode token estimate above which MCP definitions are deferred.
+ mcp_tools_token_threshold: usize,
/// Provider-specific session ID for conversation resume (e.g., Claude Code CLI session)
provider_session_id: Option,
/// Last upstream provider (OpenRouter) observed for this session
@@ -233,6 +241,9 @@ pub struct Agent {
mcp_late_register_resolved: bool,
/// Override system prompt (used by ambient mode to inject a custom prompt)
system_prompt_override: Option,
+ /// AGENTS.md is session bootstrap input. Keep the captured text stable so
+ /// tool writes do not mutate the provider's cacheable prefix mid-session.
+ agents_md_snapshot: (Option, crate::prompt::ContextInfo),
/// Whether memory features are enabled for this session
memory_enabled: bool,
/// One-step undo snapshot captured before the most recent rewind.
@@ -249,9 +260,24 @@ pub struct Agent {
/// Persists across turns so the coordinator's viewport never blanks at
/// turn boundaries or freezes during long tool calls.
inline_tail: inline_tail::InlineTailBuffer,
+ /// Prevent duplicate content uploads when shutdown/finalization is invoked
+ /// more than once for the same in-memory agent.
+ transcript_telemetry_sent: bool,
+ /// One logical runtime session, independent of the process-global legacy
+ /// telemetry slot and of any TUI clients viewing this agent.
+ concurrency_session: Option,
}
impl Agent {
+ fn refresh_agents_md_snapshot(&mut self) {
+ let working_dir = self
+ .session
+ .working_dir
+ .as_deref()
+ .map(std::path::Path::new);
+ self.agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir);
+ }
+
fn should_track_client_cache(&self) -> bool {
match std::env::var("JCODE_TRACK_CLIENT_CACHE") {
Ok(value) => {
@@ -270,8 +296,16 @@ impl Agent {
disabled_tools: HashSet,
) -> Self {
let skills = SkillRegistry::shared_snapshot();
+ let tool_config = &crate::config::config().tools;
+ let working_dir = session.working_dir.as_deref().map(std::path::Path::new);
+ let agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir);
let initial_provider_model = provider.model();
- let agent = Self {
+ let tool_policy_registration = crate::tool::register_session_tool_policy(
+ &session.id,
+ allowed_tools.clone(),
+ disabled_tools.clone(),
+ );
+ Self {
provider,
registry,
skills,
@@ -279,6 +313,9 @@ impl Agent {
active_skill: None,
allowed_tools,
disabled_tools,
+ _tool_policy_registration: tool_policy_registration,
+ mcp_tools_mode: tool_config.mcp_tools,
+ mcp_tools_token_threshold: tool_config.mcp_tools_token_threshold,
provider_session_id: None,
last_upstream_provider: None,
last_connection_type: None,
@@ -296,19 +333,16 @@ impl Agent {
locked_tools: None,
mcp_late_register_resolved: false,
system_prompt_override: None,
+ agents_md_snapshot,
memory_enabled: crate::config::config().features.memory,
rewind_undo_snapshot: None,
stdin_request_tx: None,
provider_runtime_state: ProviderRuntimeState::observed(initial_provider_model),
inline_output_tap: false,
inline_tail: inline_tail::InlineTailBuffer::default(),
- };
- crate::tool::set_session_tool_policy(
- &agent.session.id,
- agent.allowed_tools.clone(),
- agent.disabled_tools.clone(),
- );
- agent
+ transcript_telemetry_sent: false,
+ concurrency_session: None,
+ }
}
fn current_skills_snapshot(&self) -> Arc {
@@ -350,8 +384,38 @@ impl Agent {
registry: Registry,
working_dir: Option<&str>,
) -> Self {
+ Self::new_with_initial_ownership(provider, registry, working_dir, None, true)
+ }
+
+ /// A connection may only be a viewer attaching to an existing Agent.
+ /// Do not count its provisional session before that choice is resolved.
+ pub(crate) fn new_provisional_with_initial_working_dir(
+ provider: Arc,
+ registry: Registry,
+ working_dir: Option<&str>,
+ ) -> Self {
+ Self::new_with_initial_ownership(provider, registry, working_dir, None, false)
+ }
+
+ pub(crate) fn new_with_parent_and_initial_working_dir(
+ provider: Arc,
+ registry: Registry,
+ working_dir: Option<&str>,
+ parent_id: Option,
+ ) -> Self {
+ Self::new_with_initial_ownership(provider, registry, working_dir, parent_id, true)
+ }
+
+ fn new_with_initial_ownership(
+ provider: Arc,
+ registry: Registry,
+ working_dir: Option<&str>,
+ parent_id: Option,
+ track_concurrency: bool,
+ ) -> Self {
+ let start = Instant::now();
let tool_selection = crate::config::config().tools.selection();
- let mut session = Session::create(None, None);
+ let mut session = Session::create(parent_id, None);
if let Some(working_dir) = working_dir {
session.working_dir = Some(working_dir.to_string());
}
@@ -363,19 +427,30 @@ impl Agent {
tool_selection.disabled_tools,
);
agent.session.mark_active();
- agent.session.model = Some(agent.provider.model());
- agent.session.provider_key =
- crate::session::derive_session_provider_key(agent.provider.name());
+ agent.session.model = Some(agent.provider_model());
+ agent.session.provider_key = agent.provider_key_for_new_session();
+ agent.reconcile_explicit_provider_pin_route();
agent.session.ensure_initial_session_context_message();
agent.seed_compaction_from_session();
agent.log_env_snapshot("create");
agent.fire_session_lifecycle_hook("session_start", "create");
+ if track_concurrency {
+ agent.activate_concurrency_tracking();
+ }
+ let setup_ms = start.elapsed().as_millis();
+ let telemetry_start = Instant::now();
crate::telemetry::begin_session_with_parent(
agent.provider.name(),
&agent.provider.model(),
agent.session.parent_id.clone(),
false,
);
+ logging::info(&format!(
+ "[TIMING] agent_new: setup={}ms, telemetry={}ms, total={}ms",
+ setup_ms,
+ telemetry_start.elapsed().as_millis(),
+ start.elapsed().as_millis(),
+ ));
agent
}
@@ -402,8 +477,7 @@ impl Agent {
);
agent.session.mark_active();
if agent.session.provider_key.is_none() {
- agent.session.provider_key =
- crate::session::derive_session_provider_key(agent.provider.name());
+ agent.session.provider_key = agent.provider_key_for_new_session();
}
if let Some(model) = agent.session.model.clone() {
let model_request =
@@ -420,9 +494,11 @@ impl Agent {
"Failed to restore session model '{}' via '{}': {}",
model, model_request, e
));
+ } else {
+ agent.reconcile_explicit_provider_pin_route();
}
} else {
- agent.session.model = Some(agent.provider.model());
+ agent.session.model = Some(agent.provider_model());
}
agent.restore_reasoning_effort_from_session();
agent.session.ensure_initial_session_context_message();
@@ -430,6 +506,7 @@ impl Agent {
agent.seed_compaction_from_session();
agent.log_env_snapshot("attach");
agent.fire_session_lifecycle_hook("session_start", "attach");
+ agent.begin_concurrency_tracking();
crate::telemetry::begin_session_with_parent(
agent.provider.name(),
&agent.provider.model(),
@@ -569,6 +646,17 @@ impl Agent {
self.rewind_undo_snapshot = None;
}
+ /// Synchronize the remote client's selected skill, accepting only names
+ /// present in the daemon's own registry snapshot.
+ pub(super) fn set_remote_active_skill(&mut self, active_skill: Option) -> bool {
+ let skills = self.current_skills_snapshot();
+ let recognized = active_skill
+ .as_ref()
+ .is_none_or(|name| skills.get(name).is_some());
+ self.active_skill = active_skill.filter(|name| skills.get(name).is_some());
+ recognized
+ }
+
fn sync_session_compaction_state_from_manager(
&mut self,
manager: &crate::compaction::CompactionManager,
@@ -803,9 +891,20 @@ impl Agent {
let mut missing_for_message = Vec::new();
for id in tool_uses {
self.tool_call_ids.insert(id.clone());
- if !self.tool_result_ids.contains(&id) {
- missing_for_message.push(id);
+ if self.tool_result_ids.contains(&id) {
+ continue;
+ }
+ // A tool that is still executing is not an interrupted tool:
+ // its real result is on the way, and synthesizing a
+ // placeholder now produces a duplicate tool_result that
+ // Anthropic rejects outright. See `tool::inflight`.
+ if crate::tool::inflight::is_tool_in_flight(&id) {
+ logging::info(&format!(
+ "Skipping missing tool-output repair for {id}: tool is still executing"
+ ));
+ continue;
}
+ missing_for_message.push(id);
}
if !missing_for_message.is_empty() {
missing_repairs.push((index, missing_for_message));
@@ -871,16 +970,18 @@ impl Agent {
/// Mark this agent session as closed and persist it.
pub fn mark_closed(&mut self) {
- crate::telemetry::end_session_with_reason(
- self.provider.name(),
- &self.provider.model(),
- crate::telemetry::SessionEndReason::NormalExit,
- );
+ self.finish_concurrency_tracking();
self.persist_soft_interrupt_snapshot();
self.session.mark_closed();
if !self.session.messages.is_empty() {
self.persist_session_best_effort("session close state");
}
+ self.upload_transcript_telemetry(crate::telemetry::SessionEndReason::NormalExit);
+ crate::telemetry::end_session_with_reason(
+ self.provider.name(),
+ &self.provider.model(),
+ crate::telemetry::SessionEndReason::NormalExit,
+ );
self.fire_session_lifecycle_hook("session_end", "close");
}
@@ -901,15 +1002,69 @@ impl Agent {
}
pub fn mark_crashed(&mut self, message: Option) {
+ self.finish_concurrency_tracking();
+ self.persist_soft_interrupt_snapshot();
+ self.session.mark_crashed(message);
+ if !self.session.messages.is_empty() {
+ self.persist_session_best_effort("session crash state");
+ }
+ self.upload_transcript_telemetry(crate::telemetry::SessionEndReason::Unknown);
crate::telemetry::record_crash(
self.provider.name(),
&self.provider.model(),
crate::telemetry::SessionEndReason::Unknown,
);
- self.persist_soft_interrupt_snapshot();
- self.session.mark_crashed(message);
- if !self.session.messages.is_empty() {
- self.persist_session_best_effort("session crash state");
+ }
+
+ fn begin_concurrency_tracking(&mut self) {
+ // Release the old identity before registering a new one. An Agent can
+ // survive /clear and /resume, but its logical session does not.
+ self.finish_concurrency_tracking();
+ self.activate_concurrency_tracking();
+ }
+
+ /// Commit a provisional Agent to logical session ownership exactly once.
+ pub(crate) fn activate_concurrency_tracking(&mut self) {
+ if self.concurrency_session.is_some() {
+ return;
+ }
+ self.concurrency_session = Some(crate::telemetry::begin_concurrency_session(
+ &self.session.id,
+ self.session.parent_id.as_deref(),
+ ));
+ }
+
+ pub(crate) fn finish_concurrency_tracking(&mut self) {
+ if let Some(mut guard) = self.concurrency_session.take() {
+ guard.finish();
+ }
+ }
+
+ #[cfg(test)]
+ pub(crate) fn has_concurrency_tracking(&self) -> bool {
+ self.concurrency_session.is_some()
+ }
+
+ fn upload_transcript_telemetry(&mut self, end_reason: crate::telemetry::SessionEndReason) {
+ if self.transcript_telemetry_sent || self.session.messages.is_empty() {
+ return;
+ }
+ // Keep code and ordinary transcript content intact, but reuse the
+ // session export redactor so credentials are removed recursively from
+ // text, reasoning, tool inputs, and tool results before leaving the
+ // machine.
+ let redacted_session = self.session.redacted_for_export();
+ let Ok(messages) = serde_json::to_value(&redacted_session.messages) else {
+ crate::logging::warn("failed to serialize consented transcript telemetry");
+ return;
+ };
+ if crate::telemetry::record_transcript(
+ self.provider.name(),
+ &self.provider.model(),
+ end_reason,
+ messages,
+ ) {
+ self.transcript_telemetry_sent = true;
}
}
diff --git a/crates/jcode-app-core/src/agent/interrupts.rs b/crates/jcode-app-core/src/agent/interrupts.rs
index 386e6eaab0..204e15fa74 100644
--- a/crates/jcode-app-core/src/agent/interrupts.rs
+++ b/crates/jcode-app-core/src/agent/interrupts.rs
@@ -119,34 +119,44 @@ impl Agent {
/// Queue a soft interrupt message to be injected at the next safe point.
/// This method can be called even while the agent is processing (uses separate lock).
- pub fn queue_soft_interrupt(&self, content: String, urgent: bool, source: SoftInterruptSource) {
+ pub fn queue_soft_interrupt(
+ &self,
+ content: String,
+ images: Vec<(String, String)>,
+ urgent: bool,
+ source: SoftInterruptSource,
+ ) {
let content_bytes = content.len();
let content_chars = content.chars().count();
+ let image_count = images.len();
if let Ok(mut queue) = self.soft_interrupt_queue.lock() {
let pending_before = queue.len();
queue.push(SoftInterruptMessage {
content,
+ images,
urgent,
source,
});
logging::info(&format!(
- "AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} pending_before={} pending_after={}",
+ "AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} image_count={} pending_before={} pending_after={}",
self.session_id(),
source,
urgent,
content_bytes,
content_chars,
+ image_count,
pending_before,
queue.len()
));
} else {
logging::warn(&format!(
- "AGENT_SOFT_INTERRUPT_QUEUE_PUSH_FAILED session={} source={:?} urgent={} content_bytes={} content_chars={} reason=queue_lock_poisoned",
+ "AGENT_SOFT_INTERRUPT_QUEUE_PUSH_FAILED session={} source={:?} urgent={} content_bytes={} content_chars={} image_count={} reason=queue_lock_poisoned",
self.session_id(),
source,
urgent,
content_bytes,
- content_chars
+ content_chars,
+ image_count
));
}
}
@@ -348,22 +358,31 @@ impl Agent {
let mut injected = Vec::new();
let mut current_source: Option = None;
let mut current_parts: Vec = Vec::new();
+ let mut current_images: Vec<(String, String)> = Vec::new();
let flush_group = |agent: &mut Self,
injected: &mut Vec,
source: SoftInterruptSource,
- parts: &mut Vec| {
- if parts.is_empty() {
+ parts: &mut Vec,
+ images: &mut Vec<(String, String)>| {
+ if parts.is_empty() && images.is_empty() {
return;
}
let content = parts.join("\n\n");
parts.clear();
- agent.add_message_with_display_role(
- Role::User,
- vec![ContentBlock::Text {
+ let mut blocks: Vec = std::mem::take(images)
+ .into_iter()
+ .map(|(media_type, data)| ContentBlock::Image { media_type, data })
+ .collect();
+ if !content.is_empty() {
+ blocks.push(ContentBlock::Text {
text: content.clone(),
cache_control: None,
- }],
+ });
+ }
+ agent.add_message_with_display_role(
+ Role::User,
+ blocks,
soft_interrupt_session_display_role(source),
);
injected.push(InjectedSoftInterrupt { content, source });
@@ -372,17 +391,30 @@ impl Agent {
for message in messages {
match current_source {
Some(source) if source != message.source => {
- flush_group(self, &mut injected, source, &mut current_parts);
+ flush_group(
+ self,
+ &mut injected,
+ source,
+ &mut current_parts,
+ &mut current_images,
+ );
current_source = Some(message.source);
}
None => current_source = Some(message.source),
_ => {}
}
current_parts.push(message.content);
+ current_images.extend(message.images);
}
if let Some(source) = current_source {
- flush_group(self, &mut injected, source, &mut current_parts);
+ flush_group(
+ self,
+ &mut injected,
+ source,
+ &mut current_parts,
+ &mut current_images,
+ );
}
self.persist_session_best_effort("soft interrupt injection");
@@ -406,6 +438,9 @@ impl Agent {
if self.maybe_continue_incomplete_response(stop_reason, incomplete_continuations)? {
return Ok(NoToolCallOutcome::ContinueWithoutEvent);
}
+ if self.maybe_continue_stranded_tool_use(stop_reason, incomplete_continuations)? {
+ return Ok(NoToolCallOutcome::ContinueWithoutEvent);
+ }
logging::info("Turn complete - no tool calls");
let injected = self.inject_soft_interrupts();
if !injected.is_empty() {
diff --git a/crates/jcode-app-core/src/agent/model_usage_tests.rs b/crates/jcode-app-core/src/agent/model_usage_tests.rs
new file mode 100644
index 0000000000..d4046743e7
--- /dev/null
+++ b/crates/jcode-app-core/src/agent/model_usage_tests.rs
@@ -0,0 +1,199 @@
+use super::*;
+use async_trait::async_trait;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+#[derive(Clone)]
+struct UsageProvider {
+ calls: Arc,
+ model: Arc>,
+ fail: bool,
+}
+
+impl UsageProvider {
+ fn routes() -> Vec {
+ ["requested-model", "serving-model"]
+ .into_iter()
+ .map(|model| crate::provider::ModelRoute {
+ model: model.into(),
+ provider: "OpenAI".into(),
+ api_method: "openai-api-key".into(),
+ available: true,
+ detail: String::new(),
+ usage: None,
+ cheapness: None,
+ })
+ .collect()
+ }
+}
+
+#[async_trait]
+impl Provider for UsageProvider {
+ async fn complete(
+ &self,
+ _: &[Message],
+ _: &[ToolDefinition],
+ _: &str,
+ _: Option<&str>,
+ ) -> Result {
+ if self.fail {
+ anyhow::bail!("synthetic request failed");
+ }
+ let call = self.calls.fetch_add(1, Ordering::SeqCst);
+ *self.model.lock().unwrap() = "serving-model".into();
+ Ok(Box::pin(futures::stream::iter(vec![
+ Ok(StreamEvent::TextDelta("answer".into())),
+ Ok(StreamEvent::MessageEnd {
+ stop_reason: Some(if call == 0 { "max_tokens" } else { "end_turn" }.into()),
+ }),
+ ])))
+ }
+ fn name(&self) -> &str {
+ "openai"
+ }
+ fn model(&self) -> String {
+ self.model.lock().unwrap().clone()
+ }
+ fn model_routes(&self) -> Vec {
+ Self::routes()
+ }
+ fn active_resolved_credential(&self) -> Option {
+ Some(jcode_provider_core::ResolvedCredential::ApiKey)
+ }
+ fn fork(&self) -> Arc {
+ Arc::new(self.clone())
+ }
+}
+
+async fn usage_agent(fail: bool) -> Agent {
+ let provider: Arc = Arc::new(UsageProvider {
+ calls: Arc::new(AtomicUsize::new(0)),
+ model: Arc::new(std::sync::Mutex::new("requested-model".into())),
+ fail,
+ });
+ let registry = Registry::new(provider.clone()).await;
+ let mut agent = Agent::new(provider, registry);
+ agent.session.is_debug = false;
+ agent.add_message(
+ Role::User,
+ vec![ContentBlock::Text {
+ text: "test".into(),
+ cache_control: None,
+ }],
+ );
+ agent
+}
+
+#[tokio::test]
+async fn both_turn_paths_record_once_across_continuations_and_attribute_serving_model() {
+ let _home = crate::auth::test_sandbox::AuthTestSandbox::new().unwrap();
+ for (index, streaming) in [false, true].into_iter().enumerate() {
+ let mut agent = usage_agent(false).await;
+ let mut updates = Bus::global().subscribe();
+ if streaming {
+ let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
+ agent.run_turn_streaming_mpsc(tx).await.unwrap();
+ } else {
+ agent.run_turn(false).await.unwrap();
+ }
+ let routes = agent.model_routes();
+ let serving = routes
+ .iter()
+ .find(|route| route.model == "serving-model")
+ .unwrap();
+ assert_eq!(serving.usage.as_ref().unwrap().count, index as u64 + 1);
+ assert!(
+ serving
+ .usage
+ .as_ref()
+ .unwrap()
+ .last_used_unix_secs
+ .is_some()
+ );
+ let requested = routes
+ .iter()
+ .find(|route| route.model == "requested-model")
+ .unwrap();
+ assert_eq!(requested.usage.as_ref().unwrap().count, 0);
+ assert!(
+ agent
+ .session
+ .messages
+ .iter()
+ .filter(|m| m.role == Role::Assistant)
+ .count()
+ >= 2
+ );
+ let mut pushed = false;
+ while let Ok(event) = updates.try_recv() {
+ if let BusEvent::ModelUsageUpdated(route) = event {
+ assert_eq!(route.model, "serving-model");
+ pushed = true;
+ }
+ }
+ assert!(
+ pushed,
+ "metadata must refresh without taking the busy Agent lock"
+ );
+ }
+}
+
+#[tokio::test]
+async fn failed_requests_and_debug_sessions_do_not_record_turns() {
+ let home = crate::auth::test_sandbox::AuthTestSandbox::new().unwrap();
+ let mut failed = usage_agent(true).await;
+ assert!(failed.run_turn(false).await.is_err());
+ let mut debug = usage_agent(false).await;
+ debug.session.is_debug = true;
+ let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
+ debug.run_turn_streaming_mpsc(tx).await.unwrap();
+ assert!(!home.root().join("model-usage-v1.sqlite3").exists());
+ assert!(
+ debug
+ .model_routes()
+ .iter()
+ .all(|route| route.usage.is_none())
+ );
+}
+
+#[tokio::test]
+async fn resumed_session_keeps_turn_identity_until_new_input() {
+ let _home = crate::auth::test_sandbox::AuthTestSandbox::new().unwrap();
+ let mut original = usage_agent(false).await;
+ original.run_turn(false).await.unwrap();
+ let session_id = original.session.id.clone();
+ let anchor = original.session.model_usage_turn_id.clone();
+ let mut resumed = usage_agent(false).await;
+ resumed.session = crate::session::Session::load(&session_id).unwrap();
+ assert_eq!(resumed.session.model_usage_turn_id, anchor);
+ let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
+ resumed
+ .run_once_streaming_mpsc("", vec![], Some("Continue after reload".into()), tx)
+ .await
+ .unwrap();
+ let count = |agent: &Agent| {
+ agent
+ .model_routes()
+ .into_iter()
+ .find(|route| route.model == "serving-model")
+ .unwrap()
+ .usage
+ .unwrap()
+ .count
+ };
+ assert_eq!(
+ count(&resumed),
+ 1,
+ "reload continuation must not count twice"
+ );
+ assert_eq!(resumed.session.model_usage_turn_id, anchor);
+ resumed.run_once_capture("new input").await.unwrap();
+ assert_eq!(count(&resumed), 2);
+ assert_ne!(resumed.session.model_usage_turn_id, anchor);
+ assert_eq!(
+ crate::session::Session::load(&session_id)
+ .unwrap()
+ .model_usage_turn_id,
+ resumed.session.model_usage_turn_id,
+ "journal must persist the changed anchor"
+ );
+}
diff --git a/crates/jcode-app-core/src/agent/prompting.rs b/crates/jcode-app-core/src/agent/prompting.rs
index c96536450c..f4b0f91639 100644
--- a/crates/jcode-app-core/src/agent/prompting.rs
+++ b/crates/jcode-app-core/src/agent/prompting.rs
@@ -107,12 +107,13 @@ impl Agent {
.as_ref()
.map(std::path::PathBuf::from);
- let (mut split, _context_info) = crate::prompt::build_system_prompt_split(
+ let (mut split, _context_info) = crate::prompt::build_system_prompt_split_with_agents_md(
skill_prompt.as_deref(),
&available_skills,
self.session.is_canary,
memory_prompt,
working_dir.as_deref(),
+ self.agents_md_snapshot.clone(),
);
self.append_current_turn_system_reminder(&mut split);
diff --git a/crates/jcode-app-core/src/agent/provider.rs b/crates/jcode-app-core/src/agent/provider.rs
index afc9b08ca2..66780ac97d 100644
--- a/crates/jcode-app-core/src/agent/provider.rs
+++ b/crates/jcode-app-core/src/agent/provider.rs
@@ -30,7 +30,57 @@ impl Agent {
}
pub fn model_routes(&self) -> Vec {
- self.provider.model_routes()
+ let mut routes = self.provider.model_routes();
+ crate::model_usage::enrich_routes(&mut routes);
+ routes
+ }
+
+ pub(super) fn begin_model_usage_turn(&mut self, message_id: &str) {
+ self.session.model_usage_turn_id = Some(format!("{}:{}", self.session.id, message_id));
+ }
+
+ pub(super) fn model_usage_turn_id(&mut self) -> String {
+ if let Some(id) = &self.session.model_usage_turn_id {
+ return id.clone();
+ }
+ // Old sessions and direct loop callers have no durable anchor yet.
+ // Internal reminders and tool-result rows do not start a logical turn.
+ let message_id = self
+ .session
+ .visible_conversation_messages()
+ .into_iter()
+ .rev()
+ .find(|message| {
+ message.role == Role::User
+ && message.content.iter().any(|block| {
+ matches!(block, ContentBlock::Text { text, .. }
+ if !text.trim().is_empty() && !text.starts_with("[System reminder:"))
+ || matches!(block, ContentBlock::Image { .. })
+ })
+ })
+ .map(|message| message.id.clone())
+ .unwrap_or_else(|| "initial".to_string());
+ self.begin_model_usage_turn(&message_id);
+ self.session.model_usage_turn_id.clone().unwrap()
+ }
+
+ pub(super) fn record_model_turn_usage(&self, turn_id: &str) {
+ if self.session.is_debug {
+ return;
+ }
+ let Some(mut route) = crate::model_usage::serving_route(
+ self.provider.as_ref(),
+ self.session.route_api_method.as_deref(),
+ ) else {
+ return;
+ };
+ match crate::model_usage::record_turn(turn_id, &route) {
+ Ok(usage) => {
+ route.usage = Some(usage);
+ Bus::global().publish(BusEvent::ModelUsageUpdated(route));
+ }
+ Err(error) => logging::warn(&format!("Could not record model turn usage: {error}")),
+ }
}
pub fn model_catalog_snapshot(&self) -> jcode_provider_core::ModelCatalogSnapshot {
@@ -57,6 +107,21 @@ impl Agent {
Ok(())
}
+ fn refresh_compaction_budget(&self) {
+ let compaction = self.registry.compaction();
+ match compaction.try_write() {
+ Ok(mut manager) => manager.set_budget(self.provider.context_window()),
+ Err(_) => crate::logging::warn(
+ "Could not refresh compaction token budget after provider change: compaction manager is busy",
+ ),
+ }
+ }
+
+ #[cfg(test)]
+ pub(crate) async fn compaction_token_budget(&self) -> usize {
+ self.registry.compaction().read().await.token_budget()
+ }
+
pub fn provider_messages(&mut self) -> Vec {
self.session.messages_for_provider()
}
@@ -97,9 +162,10 @@ impl Agent {
let resolved_model = self.provider.model();
self.session.provider_key = Some(selection.runtime_key.stable_id());
self.session.route_api_method = Some(selection.api_method.clone());
- self.session.model = Some(resolved_model.clone());
+ self.session.model = Some(self.provider_model());
let event = crate::provider::ProviderStateEvent::selected_model(source, resolved_model);
self.provider_runtime_state.apply(event);
+ self.refresh_compaction_budget();
self.persist_session_best_effort("route selection");
self.log_env_snapshot("set_route_selection");
Ok(())
@@ -125,9 +191,10 @@ impl Agent {
self.provider.name(),
self.session.provider_key.as_deref(),
);
- self.session.model = Some(resolved_model.clone());
+ self.session.model = Some(self.provider_model());
let event = crate::provider::ProviderStateEvent::selected_model(source, resolved_model);
self.provider_runtime_state.apply(event);
+ self.refresh_compaction_budget();
self.persist_session_best_effort("model selection");
self.log_env_snapshot("set_model");
Ok(())
@@ -242,6 +309,7 @@ impl Agent {
return;
}
self.session.working_dir = Some(dir.to_string());
+ self.refresh_agents_md_snapshot();
self.session.refresh_initial_session_context_message();
self.log_env_snapshot("working_dir");
}
diff --git a/crates/jcode-app-core/src/agent/response_recovery.rs b/crates/jcode-app-core/src/agent/response_recovery.rs
index a465de3cc7..3fe2fb5e64 100644
--- a/crates/jcode-app-core/src/agent/response_recovery.rs
+++ b/crates/jcode-app-core/src/agent/response_recovery.rs
@@ -100,7 +100,7 @@ impl Agent {
true
}
- pub(super) fn should_continue_after_stop_reason(stop_reason: &str) -> bool {
+ pub(crate) fn should_continue_after_stop_reason(stop_reason: &str) -> bool {
let reason = stop_reason.trim().to_ascii_lowercase();
if reason.is_empty() {
return false;
@@ -131,6 +131,60 @@ impl Agent {
|| reason.contains("policy_violation")
}
+ pub(crate) const FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS: [&'static str; 3] = [
+ "Reassess the user's request using its full context and likely intent rather than isolated keywords. Determine whether answering would enable a concrete harmful action. If no specific safety concern applies, answer normally. Preserve the refusal only when it is genuinely warranted, and briefly identify the specific concern.",
+ "Review the request again and distinguish legitimate coding, debugging, defensive, educational, or analytical work from instructions that facilitate harm. If only part of the request is unsafe, help with the safe portions and narrowly limit the rest instead of issuing a blanket refusal.",
+ "Perform one final, independent policy check. Prefer a safe and useful constrained response when possible. Refuse only the specific content that creates a concrete safety risk; otherwise continue with the user's actual task. Do not weaken a refusal that remains genuinely necessary.",
+ ];
+
+ /// Try a small sequence of differently framed policy checks after Fable
+ /// guardrails a response. Every prompt preserves warranted refusals, and the
+ /// fixed suite size prevents an unbounded refusal/retry loop.
+ pub(crate) fn maybe_reconsider_fable_guardrail(
+ &mut self,
+ stop_reason: Option<&str>,
+ attempts: &mut u32,
+ ) -> Result {
+ let model = self.provider.model();
+ if !Self::should_reconsider_fable_guardrail(
+ &model,
+ stop_reason,
+ *attempts,
+ Self::FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS.len() as u32,
+ ) {
+ return Ok(false);
+ }
+
+ let prompt = Self::FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS[*attempts as usize];
+ *attempts += 1;
+ logging::warn(&format!(
+ "Fable 5 guardrail stopped the response (stop_reason={:?}); trying reconsideration prompt {}/{}",
+ stop_reason,
+ attempts,
+ Self::FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS.len(),
+ ));
+ self.add_message(
+ Role::User,
+ vec![ContentBlock::Text {
+ text: prompt.to_string(),
+ cache_control: None,
+ }],
+ );
+ self.session.save()?;
+ Ok(true)
+ }
+
+ pub(crate) fn should_reconsider_fable_guardrail(
+ model: &str,
+ stop_reason: Option<&str>,
+ attempts: u32,
+ max_attempts: u32,
+ ) -> bool {
+ Self::is_guardrail_stop_reason(stop_reason)
+ && model.to_ascii_lowercase().contains("fable-5")
+ && attempts < max_attempts
+ }
+
/// Builds the user-facing notice for a turn that ended with no visible
/// assistant output (no text, no tool calls). Returns `None` when the turn
/// looks normal and no notice should be surfaced.
@@ -154,17 +208,74 @@ impl Agent {
));
}
// Empty visible output with a non-guardrail stop reason: still surface,
- // since the user otherwise sees nothing at all.
+ // since the user otherwise sees nothing at all. Do not assert a content
+ // filter here: in practice this is usually a transient upstream failure
+ // (a dropped or empty stream), not a provider guardrail (issue #672).
let reasoning_hint = if had_reasoning {
" after producing only internal reasoning"
} else {
""
};
Some(format!(
- "The model ended its turn without any visible output{} (stop_reason: {}). This is usually a provider-side guardrail or filter silently dropping the response. Rephrasing the request may help.",
+ "The model ended its turn without any visible output{} (stop_reason: {}). The provider returned an empty response; this is usually a transient upstream failure rather than a content filter. Retrying the request may help.",
reasoning_hint, reason_label
))
}
+
+ /// Log-event label for an empty final turn: real guardrail stops keep the
+ /// `PROVIDER_GUARDRAIL` name, transient empty responses get their own so
+ /// the two are separable in logs (issue #672).
+ pub(crate) fn empty_turn_log_event(stop_reason: Option<&str>) -> &'static str {
+ if Self::is_guardrail_stop_reason(stop_reason) {
+ "PROVIDER_GUARDRAIL"
+ } else {
+ "PROVIDER_EMPTY_RESPONSE"
+ }
+ }
+
+ /// Retry a whitespace-only final response that arrived right after tool
+ /// results, by asking the model to produce the final answer. Shared by the
+ /// non-streaming and streaming (mpsc) turn loops so their recovery
+ /// behavior cannot drift (issue #672). Returns true when a continuation
+ /// message was injected and the caller should re-issue the request.
+ pub(crate) fn maybe_continue_empty_post_tool_response(
+ &mut self,
+ visible_text_empty: bool,
+ prompt_has_recent_tool_result: bool,
+ stop_reason: Option<&str>,
+ attempts: &mut u32,
+ ) -> Result {
+ if !visible_text_empty || !prompt_has_recent_tool_result {
+ return Ok(false);
+ }
+ // A model-side refusal is deliberate; retrying it just burns tokens.
+ if Self::is_guardrail_stop_reason(stop_reason) {
+ return Ok(false);
+ }
+ if *attempts >= Self::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS {
+ return Ok(false);
+ }
+ *attempts += 1;
+ logging::warn(&format!(
+ "Provider returned whitespace-only final response after tool results (stop_reason={:?}); requesting final answer continuation (attempt {}/{})",
+ stop_reason,
+ attempts,
+ Self::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS
+ ));
+ self.add_message(
+ Role::User,
+ vec![ContentBlock::Text {
+ // Keep this as a user-role message for provider compatibility,
+ // but mark it as internal so transcript renderers never present
+ // the synthetic recovery instruction as a prompt from the user.
+ text: "The previous provider response was empty after tool results. Provide the final answer to the user's last request using the tool results above. Do not call more tools unless absolutely necessary.".to_string(),
+ cache_control: None,
+ }],
+ );
+ self.session.save()?;
+ Ok(true)
+ }
+
fn continuation_prompt_for_stop_reason(stop_reason: &str) -> String {
format!(
"[System reminder: your previous response ended before completion (stop_reason: {}). Continue exactly where you left off, do not repeat completed content, and if the next step is a tool call, emit the tool call now.]",
@@ -215,6 +326,55 @@ impl Agent {
Ok(true)
}
+ /// True when the provider said it stopped to call a tool but no tool call
+ /// survived parsing.
+ ///
+ /// `stop_reason: tool_use` with zero tool calls is a contradiction: the
+ /// model intended to act and the harness has nothing to run. Breaking out
+ /// of the turn there strands the agent mid-task, which on a benchmark run
+ /// looks like an ordinary "the agent stopped early" failure and silently
+ /// discards all of its uncommitted work. Treat it like any other
+ /// incomplete response and ask for a continuation instead.
+ pub(crate) fn is_stranded_tool_use_stop(stop_reason: Option<&str>) -> bool {
+ stop_reason
+ .map(str::trim)
+ .map(|reason| reason.eq_ignore_ascii_case("tool_use"))
+ .unwrap_or(false)
+ }
+
+ pub(crate) fn maybe_continue_stranded_tool_use(
+ &mut self,
+ stop_reason: Option<&str>,
+ attempts: &mut u32,
+ ) -> Result {
+ if !Self::is_stranded_tool_use_stop(stop_reason) {
+ return Ok(false);
+ }
+ if *attempts >= Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS {
+ logging::warn(&format!(
+ "Provider reported stop_reason='tool_use' with no parsed tool call after {} continuation attempts; ending turn",
+ attempts
+ ));
+ return Ok(false);
+ }
+ *attempts += 1;
+ logging::warn(&format!(
+ "Provider reported stop_reason='tool_use' but no tool call was parsed; requesting continuation (attempt {}/{})",
+ attempts,
+ Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS
+ ));
+ self.add_message(
+ Role::User,
+ vec![ContentBlock::Text {
+ text: "[System reminder: your previous response ended with stop_reason \"tool_use\" but no tool call arrived. Nothing was executed. Re-issue the tool call you intended, do not repeat completed work, and continue the task.]"
+ .to_string(),
+ cache_control: None,
+ }],
+ );
+ self.session.save()?;
+ Ok(true)
+ }
+
pub(super) fn filter_truncated_tool_calls(
&mut self,
stop_reason: Option<&str>,
diff --git a/crates/jcode-app-core/src/agent/status.rs b/crates/jcode-app-core/src/agent/status.rs
index 3f8aab2a6f..f6d1b8b575 100644
--- a/crates/jcode-app-core/src/agent/status.rs
+++ b/crates/jcode-app-core/src/agent/status.rs
@@ -1,6 +1,11 @@
use super::*;
impl Agent {
+ /// Read-only source for splitting a new session before its first persistence.
+ pub(crate) fn session_for_split(&self) -> &Session {
+ &self.session
+ }
+
pub fn session_memory_profile_snapshot(
&mut self,
) -> crate::session::SessionMemoryProfileSnapshot {
@@ -157,8 +162,47 @@ impl Agent {
self.provider.display_name()
}
+ /// Reasoning effort the active provider is running with, if any.
+ pub fn provider_reasoning_effort(&self) -> Option {
+ self.provider.reasoning_effort()
+ }
+
pub fn provider_model(&self) -> String {
- self.provider.model().to_string()
+ let model = self.provider.model();
+ self.provider
+ .explicit_provider_pin_for_current_model()
+ .map(|pin| format!("{model}@{pin}"))
+ .unwrap_or(model)
+ }
+
+ pub(super) fn provider_key_for_new_session(&self) -> Option {
+ if self
+ .provider
+ .explicit_provider_pin_for_current_model()
+ .is_some()
+ {
+ // Provider pins are explicit OpenRouter route identity. Prefer that
+ // over ambient runtime env state when a CLI-created Agent snapshots
+ // a provider that was configured before the Agent existed.
+ return crate::provider::MultiProvider::session_provider_key_for_model_request(
+ &self.provider_model(),
+ self.provider.name(),
+ );
+ }
+
+ crate::session::derive_session_provider_key(self.provider.name())
+ }
+
+ pub(super) fn reconcile_explicit_provider_pin_route(&mut self) {
+ if self
+ .provider
+ .explicit_provider_pin_for_current_model()
+ .is_some()
+ {
+ self.session.model = Some(self.provider_model());
+ self.session.provider_key = Some("openrouter".to_string());
+ self.session.route_api_method = Some("openrouter".to_string());
+ }
}
/// Get the short/friendly name for this session (e.g., "fox")
diff --git a/crates/jcode-app-core/src/agent/streaming.rs b/crates/jcode-app-core/src/agent/streaming.rs
index d5fa62a6a2..a03c1dd34f 100644
--- a/crates/jcode-app-core/src/agent/streaming.rs
+++ b/crates/jcode-app-core/src/agent/streaming.rs
@@ -22,5 +22,6 @@ pub(super) fn stream_keepalive_ticker() -> time::Interval {
pub(super) fn send_stream_keepalive_mpsc(event_tx: &mpsc::UnboundedSender) {
let _ = event_tx.send(ServerEvent::Pong {
id: STREAM_KEEPALIVE_PONG_ID,
+ native_ssh_protocol: None,
});
}
diff --git a/crates/jcode-app-core/src/agent/tools.rs b/crates/jcode-app-core/src/agent/tools.rs
index 70556c5458..a0aa0ac4a8 100644
--- a/crates/jcode-app-core/src/agent/tools.rs
+++ b/crates/jcode-app-core/src/agent/tools.rs
@@ -1,4 +1,5 @@
use crate::message::{ContentBlock, ToolCall};
+use crate::terminal_println as println;
use crate::tool::ToolOutput;
pub(super) const MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY: usize = 512 * 1024;
@@ -55,6 +56,7 @@ pub(super) fn tool_output_side_pane_images(
.images
.iter()
.map(|img| jcode_session_types::RenderedImage {
+ history_message_index: None,
media_type: img.media_type.clone(),
data: img.data.clone(),
label: img
@@ -165,3 +167,30 @@ mod tests {
assert!(capped.contains("tool `custom` produced"));
}
}
+
+#[cfg(test)]
+mod image_anchor_tests {
+ use super::*;
+
+ #[test]
+ fn live_batch_images_anchor_to_parent_and_have_no_history_boundary() {
+ let output = ToolOutput::new("batch results")
+ .with_labeled_image("image/png", "one", "first.png")
+ .with_labeled_image("image/png", "two", "second.png");
+ let images =
+ tool_output_side_pane_images("parent-batch", "batch", &serde_json::json!({}), &output);
+ assert_eq!(images.len(), 2);
+ for image in &images {
+ assert_eq!(
+ image.anchor,
+ Some(jcode_session_types::RenderedImageAnchor::ToolCall {
+ id: "parent-batch".into()
+ })
+ );
+ assert_eq!(image.history_message_index, None);
+ }
+ assert_eq!(images[0].data, "one");
+ assert_eq!(images[1].data, "two");
+ assert_eq!(images[0].label.as_deref(), Some("first.png"));
+ }
+}
diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs
index a3b26e5c23..3607c0b363 100644
--- a/crates/jcode-app-core/src/agent/turn_execution.rs
+++ b/crates/jcode-app-core/src/agent/turn_execution.rs
@@ -1,15 +1,19 @@
use super::*;
+use crate::{terminal_eprintln as eprintln, terminal_println as println};
impl Agent {
/// Run a single turn with the given user message
pub async fn run_once(&mut self, user_message: &str) -> Result<()> {
- self.add_message(
+ let input_id = self.add_message(
Role::User,
vec![ContentBlock::Text {
text: user_message.to_string(),
cache_control: None,
}],
);
+ if !user_message.trim().is_empty() {
+ self.begin_model_usage_turn(&input_id);
+ }
self.session.save()?;
if trace_enabled() {
eprintln!("[trace] session_id {}", self.session.id);
@@ -19,13 +23,26 @@ impl Agent {
}
pub async fn run_once_capture(&mut self, user_message: &str) -> Result {
- self.add_message(
+ self.run_once_capture_with_display_role(user_message, None)
+ .await
+ }
+
+ pub(crate) async fn run_once_capture_with_display_role(
+ &mut self,
+ user_message: &str,
+ display_role: Option,
+ ) -> Result {
+ let input_id = self.add_message_with_display_role(
Role::User,
vec![ContentBlock::Text {
text: user_message.to_string(),
cache_control: None,
}],
+ display_role,
);
+ if !user_message.trim().is_empty() {
+ self.begin_model_usage_turn(&input_id);
+ }
self.session.save()?;
if trace_enabled() {
eprintln!("[trace] session_id {}", self.session.id);
@@ -40,6 +57,24 @@ impl Agent {
images: Vec<(String, String)>,
system_reminder: Option,
event_tx: mpsc::UnboundedSender,
+ ) -> Result<()> {
+ self.run_once_streaming_mpsc_with_display_role(
+ user_message,
+ images,
+ system_reminder,
+ event_tx,
+ None,
+ )
+ .await
+ }
+
+ pub(crate) async fn run_once_streaming_mpsc_with_display_role(
+ &mut self,
+ user_message: &str,
+ images: Vec<(String, String)>,
+ system_reminder: Option,
+ event_tx: mpsc::UnboundedSender,
+ display_role: Option,
) -> Result<()> {
// Inject any pending notifications before the user message
let alerts = self.take_alerts();
@@ -61,6 +96,32 @@ impl Agent {
self.current_turn_system_reminder =
system_reminder.filter(|value| !value.trim().is_empty());
+ self.append_user_context_message_with_display_role(user_message, images, display_role)?;
+ crate::telemetry::record_turn();
+ let turn_started_at = Instant::now();
+ let start_message_index = self.message_count();
+ self.fire_turn_start_hook("chat");
+ let result = self.run_turn_streaming_mpsc(event_tx).await;
+ self.current_turn_system_reminder = None;
+ self.fire_turn_end_hook(&result, turn_started_at, start_message_index);
+ result
+ }
+
+ /// Append and persist a user message without starting a model turn.
+ pub(crate) fn append_user_context_message(
+ &mut self,
+ user_message: &str,
+ images: Vec<(String, String)>,
+ ) -> Result<()> {
+ self.append_user_context_message_with_display_role(user_message, images, None)
+ }
+
+ fn append_user_context_message_with_display_role(
+ &mut self,
+ user_message: &str,
+ images: Vec<(String, String)>,
+ display_role: Option,
+ ) -> Result<()> {
let mut blocks: Vec = images
.into_iter()
.map(|(media_type, data)| ContentBlock::Image { media_type, data })
@@ -77,16 +138,12 @@ impl Agent {
));
}
- self.add_message(Role::User, blocks);
- crate::telemetry::record_turn();
- self.session.save()?;
- let turn_started_at = Instant::now();
- let start_message_index = self.message_count();
- self.fire_turn_start_hook("chat");
- let result = self.run_turn_streaming_mpsc(event_tx).await;
- self.current_turn_system_reminder = None;
- self.fire_turn_end_hook(&result, turn_started_at, start_message_index);
- result
+ let starts_turn = blocks.len() > 1 || !user_message.trim().is_empty();
+ let input_id = self.add_message_with_display_role(Role::User, blocks, display_role);
+ if starts_turn {
+ self.begin_model_usage_turn(&input_id);
+ }
+ self.session.save()
}
/// Fire the `turn_start` observer hook when a turn begins, before the model
@@ -150,13 +207,13 @@ impl Agent {
let preserve_working_dir = self.session.working_dir.clone();
self.session.mark_closed();
+ self.finish_concurrency_tracking();
self.persist_session_best_effort("pre-clear session close state");
let mut new_session = Session::create(None, None);
new_session.mark_active();
- new_session.model = Some(self.provider.model());
- new_session.provider_key =
- crate::session::derive_session_provider_key(self.provider.name());
+ new_session.model = Some(self.provider_model());
+ new_session.provider_key = self.provider_key_for_new_session();
new_session.is_canary = preserve_canary;
new_session.testing_build = preserve_testing_build;
new_session.is_debug = preserve_debug;
@@ -164,6 +221,14 @@ impl Agent {
new_session.ensure_initial_session_context_message();
self.session = new_session;
+ self.begin_concurrency_tracking();
+ self._tool_policy_registration = crate::tool::register_session_tool_policy(
+ &self.session.id,
+ self.allowed_tools.clone(),
+ self.disabled_tools.clone(),
+ );
+ self.refresh_agents_md_snapshot();
+ self.reconcile_explicit_provider_pin_route();
self.reset_runtime_state_for_session_change();
self.provider_session_id = None;
self.seed_compaction_from_session();
@@ -331,6 +396,21 @@ impl Agent {
self.stdin_request_tx = Some(tx);
}
+ /// Prepare the static provider prefix while a client is idle. Unlike
+ /// `tool_definitions`, this does not pin the tool snapshot or consume the
+ /// one-shot late-MCP-discovery check before the first real turn.
+ pub(crate) async fn prewarm_provider(&self) {
+ if self.session.is_canary {
+ self.registry.register_selfdev_tools().await;
+ }
+ let tools = match &self.locked_tools {
+ Some(tools) => tools.clone(),
+ None => self.build_filtered_tool_definitions().await,
+ };
+ let prompt = self.build_system_prompt_split(None);
+ self.provider.prewarm(&tools, &prompt.static_part).await;
+ }
+
pub(super) async fn tool_definitions(&mut self) -> Vec {
if self.session.is_canary {
self.registry.register_selfdev_tools().await;
@@ -354,6 +434,22 @@ impl Agent {
// prompt-cache miss (the turn MCP tools first appear). The
// `mcp_late_register_resolved` flag makes this a one-shot check so we do
// not rescan the registry on every subsequent turn.
+ let locked_uses_fixed_mcp_surface = self.locked_tools.as_ref().is_some_and(|locked| {
+ locked
+ .iter()
+ .any(|tool| matches!(tool.name.as_str(), "mcp_search" | "mcp_call"))
+ && !locked.iter().any(|tool| tool.name.starts_with("mcp__"))
+ });
+ if (self.mcp_tools_mode == crate::config::McpToolsMode::Deferred
+ || locked_uses_fixed_mcp_surface)
+ && let Some(locked) = self.locked_tools.clone()
+ {
+ // Per-server tools may continue registering in the background, but
+ // deferred mode's fixed surface cannot change as a result. Avoid an
+ // unnecessary provider cache reset and registry scan.
+ self.mcp_late_register_resolved = true;
+ return locked;
+ }
if let Some(ref locked) = self.locked_tools {
if self.mcp_late_register_resolved {
return locked.clone();
@@ -397,25 +493,53 @@ impl Agent {
async fn build_filtered_tool_definitions(&self) -> Vec {
let mut tools = self.registry.definitions(self.allowed_tools.as_ref()).await;
if !self.disabled_tools.is_empty() {
- tools.retain(|tool| !self.disabled_tools.contains(&tool.name));
+ tools.retain(|tool| {
+ !crate::tool::tool_name_is_disabled(&self.disabled_tools, &tool.name)
+ });
}
Self::apply_selfdev_tool_surface(&mut tools, self.session.is_canary);
+ self.apply_mcp_tool_exposure(&mut tools);
tools
}
- /// Tailor the `selfdev` tool definition to the session mode.
+ /// Replace per-server MCP definitions with the fixed search/call surface
+ /// according to the configured mode. Auto mode estimates the actual
+ /// serialized, already-filtered definitions the provider would receive.
+ fn apply_mcp_tool_exposure(&self, tools: &mut Vec) {
+ let mcp_definitions: Vec = tools
+ .iter()
+ .filter(|tool| tool.name.starts_with("mcp__"))
+ .cloned()
+ .collect();
+ let estimated_tokens = ToolDefinition::aggregate_prompt_token_estimate(&mcp_definitions);
+ let deferred = match self.mcp_tools_mode {
+ crate::config::McpToolsMode::Auto => estimated_tokens > self.mcp_tools_token_threshold,
+ crate::config::McpToolsMode::Eager => false,
+ crate::config::McpToolsMode::Deferred => true,
+ };
+
+ if deferred {
+ tools.retain(|tool| !tool.name.starts_with("mcp__"));
+ } else {
+ tools.retain(|tool| !matches!(tool.name.as_str(), "mcp_search" | "mcp_call"));
+ }
+ }
+
+ /// Expose the `selfdev` tool only while running in self-development mode.
///
- /// The registry stores a single shared `selfdev` tool with a default
- /// (non-self-dev) schema. Self-dev sessions get the full build/test/reload
- /// surface; every other session keeps the lightweight on-ramp surface
- /// (`enter`, `setup`, `reload`, `status`, `find-config`). The tool stays
- /// available in all sessions so the agent can always enter self-dev mode.
- fn apply_selfdev_tool_surface(tools: &mut [ToolDefinition], is_canary: bool) {
+ /// The registry keeps the implementation available for self-dev sessions,
+ /// but regular agents should not spend tool-list context on an internal
+ /// development surface.
+ fn apply_selfdev_tool_surface(tools: &mut Vec, is_canary: bool) {
+ if !is_canary {
+ tools.retain(|tool| tool.name != "selfdev");
+ return;
+ }
for tool in tools.iter_mut() {
if tool.name == "selfdev" {
tool.description =
- crate::tool::selfdev::SelfDevTool::description_for(is_canary).to_string();
- tool.input_schema = crate::tool::selfdev::SelfDevTool::schema_for(is_canary);
+ crate::tool::selfdev::SelfDevTool::description_for(true).to_string();
+ tool.input_schema = crate::tool::selfdev::SelfDevTool::schema_for(true);
}
}
}
@@ -428,8 +552,10 @@ impl Agent {
let allowed = self.allowed_tools.as_ref();
registry_names.iter().any(|name| {
name.starts_with("mcp__")
- && allowed.map(|set| set.contains(name)).unwrap_or(true)
- && !self.disabled_tools.contains(name)
+ && allowed
+ .map(|set| crate::tool::tool_name_is_allowed(set, name))
+ .unwrap_or(true)
+ && !crate::tool::tool_name_is_disabled(&self.disabled_tools, name)
&& !locked.iter().any(|t| &t.name == name)
})
}
@@ -447,12 +573,7 @@ impl Agent {
if self.session.is_canary {
self.registry.register_selfdev_tools().await;
}
- let mut tools = self.registry.definitions(self.allowed_tools.as_ref()).await;
- if !self.disabled_tools.is_empty() {
- tools.retain(|tool| !self.disabled_tools.contains(&tool.name));
- }
- Self::apply_selfdev_tool_surface(&mut tools, self.session.is_canary);
- tools
+ self.build_filtered_tool_definitions().await
}
pub async fn execute_tool(
@@ -530,11 +651,11 @@ impl Agent {
pub(super) fn validate_tool_allowed(&self, name: &str) -> Result<()> {
if let Some(allowed) = self.allowed_tools.as_ref()
- && !allowed.contains(name)
+ && !crate::tool::tool_name_is_allowed(allowed, name)
{
return Err(anyhow::anyhow!("Tool '{}' is not allowed", name));
}
- if self.disabled_tools.contains(name) {
+ if crate::tool::tool_name_is_disabled(&self.disabled_tools, name) {
return Err(anyhow::anyhow!("Tool '{}' is disabled", name));
}
Ok(())
@@ -568,12 +689,14 @@ impl Agent {
let previous_status = session.status.clone();
let assign_start = Instant::now();
- let previous_session_id = self.session.id.clone();
+ // A failed load must leave the current Agent and its concurrency lease
+ // alive. Close it only after the replacement is ready to install.
+ self.mark_closed();
// Restore provider_session_id for Claude CLI session resume
self.provider_session_id = session.provider_session_id.clone();
self.session = session;
- crate::tool::clear_session_tool_policy(&previous_session_id);
- crate::tool::set_session_tool_policy(
+ self.refresh_agents_md_snapshot();
+ self._tool_policy_registration = crate::tool::register_session_tool_policy(
&self.session.id,
self.allowed_tools.clone(),
self.disabled_tools.clone(),
@@ -600,15 +723,18 @@ impl Agent {
"Failed to restore session model '{}' via '{}': {}",
model, model_request, e
));
+ } else {
+ self.reconcile_explicit_provider_pin_route();
}
} else {
- self.session.model = Some(self.provider.model());
+ self.session.model = Some(self.provider_model());
}
self.restore_reasoning_effort_from_session();
let model_ms = model_start.elapsed().as_millis();
let mark_active_start = Instant::now();
self.session.mark_active();
+ self.begin_concurrency_tracking();
let mark_active_ms = mark_active_start.elapsed().as_millis();
self.sync_memory_dedup_state_from_session();
@@ -662,6 +788,7 @@ impl Agent {
crate::session::render_messages(&self.session)
.into_iter()
.map(|msg| HistoryMessage {
+ response_stats: msg.response_stats,
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
@@ -681,6 +808,7 @@ impl Agent {
let history = messages
.into_iter()
.map(|msg| HistoryMessage {
+ response_stats: msg.response_stats,
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
@@ -710,6 +838,7 @@ impl Agent {
let history = messages
.into_iter()
.map(|msg| HistoryMessage {
+ response_stats: msg.response_stats,
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
@@ -769,8 +898,11 @@ impl Agent {
continue;
}
- // Check for skill invocation
- if let Some(invocation) = SkillRegistry::parse_invocation(input) {
+ // Check for skill invocation. Resolve against the registry (not
+ // the bare tokenizer) so a `SKILL.md` `name:` field containing
+ // spaces, e.g. "My Custom Skill", can still be matched: the
+ // bare parse always stops at the first whitespace.
+ if let Some(invocation) = skills.resolve_invocation(input) {
if let Some(skill) = skills.get(invocation.name) {
println!("Activating skill: {}", skill.name);
println!("{}\n", skill.description);
@@ -838,6 +970,9 @@ impl Agent {
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
+ if text.trim_start().starts_with("") {
+ continue;
+ }
transcript.push_str(text);
transcript.push('\n');
}
diff --git a/crates/jcode-app-core/src/agent/turn_loops.rs b/crates/jcode-app-core/src/agent/turn_loops.rs
index f0f917ebe6..25e857b314 100644
--- a/crates/jcode-app-core/src/agent/turn_loops.rs
+++ b/crates/jcode-app-core/src/agent/turn_loops.rs
@@ -1,14 +1,50 @@
use super::*;
+use crate::{terminal_eprintln as eprintln, terminal_print as print, terminal_println as println};
impl Agent {
+ /// Speculatively prewarm the provider while a newly created session is idle.
+ ///
+ /// This deliberately bypasses `tool_definitions`, whose cache lock is only
+ /// appropriate once an actual turn starts. Late MCP registration or user
+ /// customization can therefore still change the foreground tool snapshot;
+ /// the provider is responsible for discarding an incompatible warmup.
+ pub(crate) async fn prewarm_provider_idle(&self) {
+ let tools = self.tool_definitions_for_debug().await;
+ let split_prompt = self.build_system_prompt_split(None);
+ self.provider
+ .prewarm(&tools, &split_prompt.static_part)
+ .await;
+ }
+
/// Run turns until no more tool calls
/// Maximum number of context-limit compaction retries before giving up.
pub(super) const MAX_CONTEXT_LIMIT_RETRIES: u32 = 5;
pub(super) const MAX_INCOMPLETE_CONTINUATION_ATTEMPTS: u32 = 3;
- pub(super) const MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS: u32 = 1;
+ /// Retries allowed when the provider returns an empty response right after
+ /// tool results. This is a transient provider hiccup, not a signal that the
+ /// task is finished, so a single retry is too few: one empty response
+ /// observed once in 43 turns silently ended a 20-hour benchmark run with the
+ /// task half-done. The counter is per turn-loop, so a genuinely finished
+ /// agent still exits promptly.
+ pub(crate) const MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS: u32 = 5;
+ const SEQUENTIAL_TOOL_ROUNDS_BEFORE_BATCH_NUDGE: u32 = 3;
+ const BATCH_NUDGE: &str = "Several tool calls have been made one at a time. If the next independent operations can run concurrently, use the batch tool instead of making more sequential calls. Keep sequential calls when one result is required to decide the next operation.";
+
+ fn update_sequential_tool_rounds(current: u32, tool_count: usize, used_batch: bool) -> u32 {
+ if tool_count == 1 && !used_batch {
+ current.saturating_add(1)
+ } else {
+ 0
+ }
+ }
+
+ fn should_inject_batch_nudge(pending: bool, batch_available: bool) -> bool {
+ pending && batch_available
+ }
pub(super) async fn run_turn(&mut self, print_output: bool) -> Result {
self.set_log_context();
+ let usage_turn_id = self.model_usage_turn_id();
crate::session_metrics::record_turn(&self.session.id);
// Mark this session as actively streaming for presence UIs (e.g. the
// macOS menu bar indicator). Cleared automatically on every exit path.
@@ -24,8 +60,18 @@ impl Agent {
let mut context_limit_retries = 0u32;
let mut incomplete_continuations = 0u32;
let mut empty_post_tool_continuations = 0u32;
+ let mut fable_guardrail_reconsiderations = 0u32;
+ let mut sequential_single_tool_rounds = 0u32;
+ let mut batch_nudge_pending = false;
loop {
+ // Do not start another provider request once a cancel has been
+ // observed; the loop is re-entered by several recovery paths
+ // (issue #732, regression of #428).
+ if self.is_graceful_shutdown() {
+ logging::info("Cancel observed at turn-loop head - not starting another request");
+ break;
+ }
let repaired = self.repair_missing_tool_outputs();
if repaired > 0 {
logging::warn(&format!(
@@ -33,6 +79,14 @@ impl Agent {
repaired
));
}
+ // Start provider transport setup before deriving and potentially
+ // compacting the request history. This is the first point where the
+ // stable request settings are available.
+ let mut tools = self.tool_definitions().await;
+ let mut split_prompt = self.build_system_prompt_split(None);
+ self.provider
+ .prewarm(&tools, &split_prompt.static_part)
+ .await;
let (messages, compaction_event) = self.messages_for_provider();
if let Some(event) = compaction_event {
// Reset cache tracker and tool lock on compaction since the message history changes
@@ -43,17 +97,23 @@ impl Agent {
.pre_tokens
.map(|t| format!(" ({} tokens)", t))
.unwrap_or_default();
- println!("📦 Context compacted ({}){}", event.trigger, tokens_str);
+ crate::terminal_println!(
+ "📦 Context compacted ({}){}",
+ event.trigger,
+ tokens_str
+ );
}
+ // Compaction clears the tool lock, so rebuild the foreground
+ // request metadata rather than relying on the pre-compaction snapshot.
+ tools = self.tool_definitions().await;
+ split_prompt = self.build_system_prompt_split(None);
}
- let tools = self.tool_definitions().await;
let messages: std::sync::Arc<[Message]> = messages.into();
// Non-blocking memory: uses pending result from last turn, spawns check for next turn
let memory_pending =
self.build_memory_prompt_nonblocking_shared(std::sync::Arc::clone(&messages), None);
// Use split prompt for better caching - static content cached, dynamic not
- let split_prompt = self.build_system_prompt_split(None);
self.log_prompt_prefix_accounting(&split_prompt, &tools);
// Check for client-side cache violations before memory injection.
@@ -61,6 +121,10 @@ impl Agent {
// false-positive violations every turn (prior turn's memory ≠ current history prefix).
self.record_client_cache_request(&messages);
+ // The request snapshot now owns everything the provider needs. Drop
+ // the session's derived transcript copy before the network wait.
+ self.session.release_provider_messages_cache();
+
// Inject memory as a user message at the end (preserves cache prefix)
let mut messages_with_memory: Vec = messages.iter().cloned().collect();
if let Some(memory) = memory_pending.as_ref() {
@@ -75,6 +139,14 @@ impl Agent {
let (memory_msg, _persisted) = self.prepare_memory_injection_message(memory);
messages_with_memory.push(memory_msg);
}
+ if Self::should_inject_batch_nudge(
+ batch_nudge_pending,
+ tools.iter().any(|tool| tool.name == "batch"),
+ ) {
+ messages_with_memory.push(Message::user(Self::BATCH_NUDGE));
+ batch_nudge_pending = false;
+ sequential_single_tool_rounds = 0;
+ }
logging::info(&format!(
"API call starting: {} messages, {} tools",
@@ -90,13 +162,11 @@ impl Agent {
model: Some(self.provider.model()),
}));
- let stamped;
- let send_messages: &[Message] = if crate::config::config().features.message_timestamps {
- stamped = Message::with_timestamps(&messages_with_memory);
- &stamped
- } else {
- &messages_with_memory
- };
+ let stamped = crate::config::config()
+ .features
+ .message_timestamps
+ .then(|| Message::with_timestamps(&messages_with_memory));
+ let send_messages = stamped.as_deref().unwrap_or(&messages_with_memory);
let prompt_has_recent_tool_result = Self::messages_end_with_tool_result(send_messages);
self.last_status_detail = None;
let mut stream = match self
@@ -129,6 +199,14 @@ impl Agent {
}
};
+ // The provider returned an owned stream, so the request transcript
+ // copies are no longer needed while the response is consumed.
+ drop(stamped);
+ drop(messages_with_memory);
+ drop(memory_pending);
+ drop(messages);
+ drop(split_prompt);
+
// Successful API call - reset retry counter
context_limit_retries = 0;
@@ -226,7 +304,7 @@ impl Agent {
StreamEvent::ThinkingDelta(thinking_text) => {
// Display reasoning content only if enabled
if print_output && crate::config::config().display.show_thinking {
- println!("💭 {}", thinking_text);
+ crate::terminal_println!("💭 {}", thinking_text);
}
// Always capture reasoning text so it can be persisted as a
// history-only trace, regardless of provider replay support.
@@ -249,7 +327,7 @@ impl Agent {
}
StreamEvent::TextDelta(text) => {
if print_output {
- print!("{}", text);
+ crate::terminal_print!("{}", text);
io::stdout().flush()?;
}
text_content.push_str(&text);
@@ -517,7 +595,11 @@ impl Agent {
let tokens_str = pre_tokens
.map(|t| format!(" ({} tokens)", t))
.unwrap_or_default();
- println!("📦 Context compacted ({}){}", trigger, tokens_str);
+ crate::terminal_println!(
+ "📦 Context compacted ({}){}",
+ trigger,
+ tokens_str
+ );
}
}
StreamEvent::NativeToolCall {
@@ -741,6 +823,7 @@ impl Agent {
self.add_message_ext(Role::Assistant, content_blocks, None, token_usage);
self.push_embedding_snapshot_if_semantic(&text_content);
self.session.save()?;
+ self.record_model_turn_usage(&usage_turn_id);
Some(message_id)
} else {
None
@@ -772,25 +855,18 @@ impl Agent {
// If no tool calls, we're done
if tool_calls.is_empty() {
- if visible_text_is_empty
- && prompt_has_recent_tool_result
- && empty_post_tool_continuations
- < Self::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS
- {
- empty_post_tool_continuations += 1;
- logging::warn(&format!(
- "Provider returned whitespace-only final response after tool results; requesting final answer continuation (attempt {}/{})",
- empty_post_tool_continuations,
- Self::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS
- ));
- self.add_message(
- Role::User,
- vec![ContentBlock::Text {
- text: "The previous provider response was empty after tool results. Please provide the final answer to the user's last request using the tool results above. Do not call more tools unless absolutely necessary.".to_string(),
- cache_control: None,
- }],
- );
- self.session.save()?;
+ if self.maybe_reconsider_fable_guardrail(
+ stop_reason.as_deref(),
+ &mut fable_guardrail_reconsiderations,
+ )? {
+ continue;
+ }
+ if self.maybe_continue_empty_post_tool_response(
+ visible_text_is_empty,
+ prompt_has_recent_tool_result,
+ stop_reason.as_deref(),
+ &mut empty_post_tool_continuations,
+ )? {
continue;
}
if self.maybe_continue_incomplete_response(
@@ -807,7 +883,8 @@ impl Agent {
!reasoning_content.trim().is_empty(),
) {
logging::warn(&format!(
- "PROVIDER_GUARDRAIL: turn ended with no visible output (stop_reason={:?})",
+ "{}: turn ended with no visible output (stop_reason={:?})",
+ Self::empty_turn_log_event(stop_reason.as_deref()),
stop_reason
));
if print_output {
@@ -850,6 +927,16 @@ impl Agent {
logging::info("Provider handles tools internally - executing native tools locally");
}
+ let used_batch = tool_calls.iter().any(|tc| tc.name == "batch");
+ sequential_single_tool_rounds = Self::update_sequential_tool_rounds(
+ sequential_single_tool_rounds,
+ tool_calls.len(),
+ used_batch,
+ );
+ if sequential_single_tool_rounds >= Self::SEQUENTIAL_TOOL_ROUNDS_BEFORE_BATCH_NUDGE {
+ batch_nudge_pending = true;
+ }
+
// Execute tools and add results
let mut tool_results_dirty = false;
for tc in tool_calls {
@@ -1093,7 +1180,7 @@ impl Agent {
Ok(final_text)
}
- fn messages_end_with_tool_result(messages: &[Message]) -> bool {
+ pub(super) fn messages_end_with_tool_result(messages: &[Message]) -> bool {
messages.iter().rev().any(|message| {
if !matches!(message.role, Role::User) {
return false;
@@ -1170,4 +1257,30 @@ mod tests {
assert!(!Agent::messages_end_with_tool_result(&messages));
}
+
+ #[test]
+ fn sequential_tool_rounds_trigger_after_three_single_calls() {
+ let mut rounds = 0;
+ for _ in 0..3 {
+ rounds = Agent::update_sequential_tool_rounds(rounds, 1, false);
+ }
+
+ assert_eq!(rounds, Agent::SEQUENTIAL_TOOL_ROUNDS_BEFORE_BATCH_NUDGE);
+ }
+
+ #[test]
+ fn parallel_or_batch_calls_reset_sequential_tool_rounds() {
+ assert_eq!(Agent::update_sequential_tool_rounds(2, 2, false), 0);
+ assert_eq!(Agent::update_sequential_tool_rounds(2, 1, true), 0);
+ assert_eq!(Agent::update_sequential_tool_rounds(2, 0, false), 0);
+ }
+
+ #[test]
+ fn pending_nudge_is_injected_only_when_batch_is_available() {
+ assert!(Agent::should_inject_batch_nudge(true, true));
+ assert!(!Agent::should_inject_batch_nudge(false, true));
+ assert!(!Agent::should_inject_batch_nudge(true, false));
+ assert!(Agent::BATCH_NUDGE.contains("use the batch tool"));
+ assert!(Agent::BATCH_NUDGE.contains("result is required"));
+ }
}
diff --git a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs
index 60d6a6ae00..0374667be4 100644
--- a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs
+++ b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs
@@ -81,6 +81,7 @@ impl Agent {
event_tx: mpsc::UnboundedSender,
) -> Result<()> {
self.set_log_context();
+ let usage_turn_id = self.model_usage_turn_id();
// Mark this session as actively streaming for presence UIs (e.g. the
// macOS menu bar indicator). Cleared automatically on every exit path.
let _streaming_guard = crate::session::StreamingGuard::new(self.session.id.clone());
@@ -95,8 +96,20 @@ impl Agent {
let trace = trace_enabled();
let mut context_limit_retries = 0u32;
let mut incomplete_continuations = 0u32;
+ let mut empty_post_tool_continuations = 0u32;
+ let mut fable_guardrail_reconsiderations = 0u32;
loop {
+ // Never open a new provider request after a cancel. Several paths
+ // `continue` this loop (compaction retry, incomplete/stranded
+ // continuation, empty-response recovery, soft-interrupt injection),
+ // and without this check an Esc that landed during a stream could
+ // be followed by another full request, which looks to the user like
+ // the interrupt was ignored (issue #732, regression of #428).
+ if self.is_graceful_shutdown() {
+ logging::info("Cancel observed at turn-loop head - not starting another request");
+ break;
+ }
let repaired = self.repair_missing_tool_outputs();
if repaired > 0 {
logging::warn(&format!(
@@ -104,6 +117,14 @@ impl Agent {
repaired
));
}
+ // Start provider transport setup before deriving and potentially
+ // compacting the request history. This is the first point where the
+ // stable request settings are available.
+ let mut tools = self.tool_definitions().await;
+ let mut split_prompt = self.build_system_prompt_split(None);
+ self.provider
+ .prewarm(&tools, &split_prompt.static_part)
+ .await;
let (messages, compaction_event) = self.messages_for_provider();
if let Some(event) = compaction_event {
// Reset cache tracker and tool lock on compaction since the message history changes
@@ -123,14 +144,17 @@ impl Agent {
post_tokens: event.post_tokens,
tokens_saved: event.tokens_saved,
duration_ms: event.duration_ms,
- messages_dropped: None,
+ messages_dropped: event.messages_dropped,
messages_compacted: event.messages_compacted,
summary_chars: event.summary_chars,
active_messages: event.active_messages,
});
+ // Compaction clears the tool lock, so rebuild the foreground
+ // request metadata rather than relying on the pre-compaction snapshot.
+ tools = self.tool_definitions().await;
+ split_prompt = self.build_system_prompt_split(None);
}
- let tools = self.tool_definitions().await;
let messages: std::sync::Arc<[Message]> = messages.into();
// Non-blocking memory: uses pending result from last turn, spawns check for next turn
let memory_pending = self.build_memory_prompt_nonblocking_shared(
@@ -143,7 +167,6 @@ impl Agent {
})),
);
// Use split prompt for better caching - static content cached, dynamic not
- let split_prompt = self.build_system_prompt_split(None);
self.log_prompt_prefix_accounting(&split_prompt, &tools);
// Check for client-side cache violations before memory injection.
@@ -151,6 +174,11 @@ impl Agent {
// false-positive violations every turn (prior turn's memory ≠ current history prefix).
self.record_client_cache_request(&messages);
+ // `messages` now owns the provider-facing request snapshot. Do not
+ // retain the session's second, derived copy for the entire network
+ // wait and response stream.
+ self.session.release_provider_messages_cache();
+
let mut cache_signature_messages =
if crate::config::config().features.message_timestamps {
Message::with_timestamps(&messages)
@@ -193,13 +221,12 @@ impl Agent {
));
let api_start = Instant::now();
- let stamped;
- let send_messages: &[Message] = if crate::config::config().features.message_timestamps {
- stamped = Message::with_timestamps(&messages_with_memory);
- &stamped
- } else {
- &messages_with_memory
- };
+ let stamped = crate::config::config()
+ .features
+ .message_timestamps
+ .then(|| Message::with_timestamps(&messages_with_memory));
+ let send_messages = stamped.as_deref().unwrap_or(&messages_with_memory);
+ let prompt_has_recent_tool_result = Self::messages_end_with_tool_result(send_messages);
let provider = Arc::clone(&self.provider);
// Capture the model id the request was issued with. A provider may
// transparently switch models mid-request (e.g. Anthropic's retired
@@ -217,6 +244,11 @@ impl Agent {
&split_prompt.static_part,
&ephemeral_signature_messages,
));
+ // These vectors are only needed to build the cache telemetry event.
+ // Explicitly release their deeply cloned transcript strings before
+ // waiting for the provider stream.
+ drop(cache_signature_messages);
+ drop(ephemeral_signature_messages);
let mut keepalive = stream_keepalive_ticker();
let mut stream = {
let mut complete_future = std::pin::pin!(provider.complete_split(
@@ -273,6 +305,15 @@ impl Agent {
}
};
+ // `complete_split` has consumed the request and returned an owned
+ // response stream. Keeping these full transcript snapshots alive
+ // while tokens arrive needlessly multiplies active-session memory.
+ drop(stamped);
+ drop(messages_with_memory);
+ drop(memory_pending);
+ drop(messages);
+ drop(split_prompt);
+
// Successful API call - reset retry counter
context_limit_retries = 0;
@@ -329,7 +370,7 @@ impl Agent {
// to clients as a keepalive; throttles issue #451 keepalives.
let mut hidden_activity_last = Instant::now();
let mut openai_reasoning_items: Vec = Vec::new();
- let mut openai_native_compaction: Option<(String, usize)> = None;
+ let mut openai_native_compaction: Option<(String, usize, Option)> = None;
let mut tool_id_to_name: std::collections::HashMap =
std::collections::HashMap::new();
@@ -452,10 +493,12 @@ impl Agent {
}
}
StreamEvent::ThinkingDelta(thinking_text) => {
- // Only send thinking content if enabled in config
- if crate::config::config().display.show_thinking
- && !thinking_text.is_empty()
- {
+ // Always stream reasoning to clients. Whether to *render*
+ // it is a per-client presentation choice (the TUI keys off
+ // `display.reasoning_display`, the desktops off their own
+ // mode); gating it here would let one shared daemon config
+ // decide what every attached client is allowed to see.
+ if !thinking_text.is_empty() {
reasoning_open = true;
let _ = event_tx.send(ServerEvent::ReasoningDelta {
text: thinking_text.clone(),
@@ -463,13 +506,10 @@ impl Agent {
} else if hidden_activity_last.elapsed()
>= std::time::Duration::from_secs(5)
{
- // Hidden reasoning is real provider activity, but it
- // emits nothing over the client socket, so a long
- // silent thinking phase looks identical to a dead
- // connection and the client stall guard cancels a
- // healthy stream (issue #451). Send a throttled
- // non-rendered keepalive so clients track provider
- // activity, not just displayable events.
+ // An empty delta carries provider activity but no
+ // event, so a long silent thinking phase would look
+ // identical to a dead connection and the client stall
+ // guard would cancel a healthy stream (issue #451).
hidden_activity_last = Instant::now();
send_stream_keepalive_mpsc(&event_tx);
}
@@ -759,7 +799,9 @@ impl Agent {
if reason.is_some() {
stop_reason = reason;
}
- let _ = event_tx.send(ServerEvent::MessageEnd);
+ let _ = event_tx.send(ServerEvent::MessageEnd {
+ stop_reason: stop_reason.clone(),
+ });
}
StreamEvent::SessionId(sid) => {
self.provider_session_id = Some(sid.clone());
@@ -782,12 +824,16 @@ impl Agent {
}
}
StreamEvent::Compaction {
+ pre_tokens,
openai_encrypted_content,
..
} => {
if let Some(encrypted_content) = openai_encrypted_content {
- openai_native_compaction
- .get_or_insert((encrypted_content, self.session.messages.len()));
+ openai_native_compaction.get_or_insert((
+ encrypted_content,
+ self.session.messages.len(),
+ pre_tokens,
+ ));
}
}
StreamEvent::NativeToolCall {
@@ -984,7 +1030,7 @@ impl Agent {
"Provider switched model mid-request: '{}' -> '{}' (resyncing session/UI)",
model_at_request_start, model_after_stream
));
- self.session.model = Some(model_after_stream.clone());
+ self.session.model = Some(self.provider_model());
self.provider_runtime_state.apply(
crate::provider::ProviderStateEvent::RuntimeModelObserved {
model: model_after_stream.clone(),
@@ -1063,13 +1109,37 @@ impl Agent {
self.add_message_ext(Role::Assistant, content_blocks, None, token_usage);
self.push_embedding_snapshot_if_semantic(&text_content);
self.session.save()?;
+ self.record_model_turn_usage(&usage_turn_id);
Some(message_id)
} else {
None
};
- if let Some((encrypted_content, compacted_count)) = openai_native_compaction.take() {
+ if let Some((encrypted_content, compacted_count, native_pre_tokens)) =
+ openai_native_compaction.take()
+ {
self.apply_openai_native_compaction(encrypted_content, compacted_count)?;
+ // Native OpenAI compaction is applied after the provider stream,
+ // so `messages_for_provider()` did not have an event to emit at
+ // the top of this iteration. Notify clients now, before any
+ // tool-driven continuation can enqueue its next KvCacheRequest.
+ // The FIFO event ordering lets the TUI invalidate its old
+ // append-only baseline before seeing the compacted signature.
+ //
+ // Only a provider-supplied pre-compaction count is trustworthy
+ // here. The response's own input usage is not the pre-compaction
+ // context size, so omit the value rather than mislabel it (#1178).
+ let _ = event_tx.send(ServerEvent::Compaction {
+ trigger: "openai_native".to_string(),
+ pre_tokens: native_pre_tokens,
+ post_tokens: None,
+ tokens_saved: None,
+ duration_ms: None,
+ messages_dropped: None,
+ messages_compacted: Some(compacted_count),
+ summary_chars: None,
+ active_messages: None,
+ });
}
// If stop_reason indicates truncation (e.g. max_tokens), discard tool calls
@@ -1096,6 +1166,29 @@ impl Agent {
// Injecting before tool_results would break the API requirement that
// tool_use must be immediately followed by tool_result.
if tool_calls.is_empty() {
+ if saw_message_end
+ && !self.is_graceful_shutdown()
+ && self.maybe_reconsider_fable_guardrail(
+ stop_reason.as_deref(),
+ &mut fable_guardrail_reconsiderations,
+ )?
+ {
+ continue;
+ }
+ // Retry transient empty responses (dropped/empty upstream
+ // streams) before surfacing anything, matching the
+ // non-streaming loop's recovery behavior (issue #672).
+ if saw_message_end
+ && !self.is_graceful_shutdown()
+ && self.maybe_continue_empty_post_tool_response(
+ text_content.trim().is_empty(),
+ prompt_has_recent_tool_result,
+ stop_reason.as_deref(),
+ &mut empty_post_tool_continuations,
+ )?
+ {
+ continue;
+ }
match self.handle_streaming_no_tool_calls(
stop_reason.as_deref(),
&mut incomplete_continuations,
@@ -1116,7 +1209,8 @@ impl Agent {
)
{
logging::warn(&format!(
- "PROVIDER_GUARDRAIL: turn ended with no visible output (stop_reason={:?}, reasoning_chars={})",
+ "{}: turn ended with no visible output (stop_reason={:?}, reasoning_chars={})",
+ Self::empty_turn_log_event(stop_reason.as_deref()),
stop_reason,
reasoning_content.len()
));
diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs
index 8871b44d27..026e3a4bae 100644
--- a/crates/jcode-app-core/src/agent_tests.rs
+++ b/crates/jcode-app-core/src/agent_tests.rs
@@ -8,6 +8,12 @@ use async_trait::async_trait;
use tokio::sync::mpsc as tokio_mpsc;
use tokio_stream::wrappers::ReceiverStream;
+#[path = "agent_tests/concurrency.rs"]
+mod concurrency;
+
+#[path = "agent_tests/concurrency_construction.rs"]
+mod concurrency_construction;
+
struct DelayedProvider {
open_delay: Duration,
first_event_delay: Duration,
@@ -15,6 +21,69 @@ struct DelayedProvider {
struct NativeAutoCompactionProvider;
+struct NativeCompactionStreamProvider;
+
+#[derive(Clone)]
+struct ExplicitPinProvider {
+ model: Arc>,
+ pin: Arc>>,
+ set_model_requests: Arc>>,
+}
+
+impl ExplicitPinProvider {
+ fn new(model: &str) -> Self {
+ Self {
+ model: Arc::new(std::sync::Mutex::new(model.to_string())),
+ pin: Arc::new(std::sync::Mutex::new(None)),
+ set_model_requests: Arc::new(std::sync::Mutex::new(Vec::new())),
+ }
+ }
+}
+
+#[async_trait]
+impl Provider for ExplicitPinProvider {
+ async fn complete(
+ &self,
+ _messages: &[Message],
+ _tools: &[ToolDefinition],
+ _system: &str,
+ _resume_session_id: Option<&str>,
+ ) -> Result {
+ unreachable!("ExplicitPinProvider does not complete requests")
+ }
+
+ fn name(&self) -> &str {
+ "openrouter"
+ }
+
+ fn model(&self) -> String {
+ self.model.lock().unwrap().clone()
+ }
+
+ fn set_model(&self, request: &str) -> Result<()> {
+ self.set_model_requests
+ .lock()
+ .unwrap()
+ .push(request.to_string());
+ let spec = request.strip_prefix("openrouter:").unwrap_or(request);
+ let (model, pin) = spec
+ .rsplit_once('@')
+ .map(|(model, pin)| (model, Some(pin.to_string())))
+ .unwrap_or((spec, None));
+ *self.model.lock().unwrap() = model.to_string();
+ *self.pin.lock().unwrap() = pin;
+ Ok(())
+ }
+
+ fn explicit_provider_pin_for_current_model(&self) -> Option {
+ self.pin.lock().unwrap().clone()
+ }
+
+ fn fork(&self) -> Arc {
+ Arc::new(self.clone())
+ }
+}
+
fn content_text(content: &[ContentBlock]) -> &str {
match content.first() {
Some(ContentBlock::Text { text, .. }) => text,
@@ -26,6 +95,102 @@ fn message_text(message: &Message) -> &str {
content_text(&message.content)
}
+#[test]
+fn agent_drop_removes_its_configured_session_tool_policy() {
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let session = Session::create(None, None);
+ let session_id = session.id.clone();
+ let agent = Agent::new_with_session(
+ provider,
+ Registry::empty(),
+ session,
+ Some(HashSet::from(["bash".to_string()])),
+ );
+
+ assert_eq!(
+ crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "bash"),
+ Some(true)
+ );
+ drop(agent);
+ assert_eq!(
+ crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "bash"),
+ None,
+ "dropping the Agent must remove its global policy entry"
+ );
+}
+
+#[test]
+fn stale_agent_drop_preserves_successor_session_tool_policy() {
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let first_session = Session::create(None, None);
+ let session_id = first_session.id.clone();
+ let first = Agent::new_with_session(
+ provider.clone(),
+ Registry::empty(),
+ first_session,
+ Some(HashSet::from(["bash".to_string()])),
+ );
+ let mut successor_session = Session::create(None, None);
+ successor_session.id.clone_from(&session_id);
+ let successor = Agent::new_with_session(
+ provider,
+ Registry::empty(),
+ successor_session,
+ Some(HashSet::from(["read".to_string()])),
+ );
+
+ drop(first);
+
+ assert_eq!(
+ crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "read"),
+ Some(true),
+ "a stale Agent must not remove its active successor's policy"
+ );
+ assert_eq!(
+ crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "bash"),
+ Some(false),
+ "the surviving entry must be the successor's configured policy"
+ );
+ drop(successor);
+ assert_eq!(
+ crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "read"),
+ None
+ );
+}
+
+#[test]
+fn agent_clear_moves_tool_policy_registration_to_new_session() {
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let session = Session::create(None, None);
+ let previous_session_id = session.id.clone();
+ let mut agent = Agent::new_with_session(
+ provider,
+ Registry::empty(),
+ session,
+ Some(HashSet::from(["bash".to_string()])),
+ );
+
+ agent.clear();
+ let new_session_id = agent.session.id.clone();
+
+ assert_ne!(previous_session_id, new_session_id);
+ assert_eq!(
+ crate::tool::session_tool_policy_allows_tool_for_test(&previous_session_id, "bash"),
+ None,
+ "changing sessions must remove the former ID's policy"
+ );
+ assert_eq!(
+ crate::tool::session_tool_policy_allows_tool_for_test(&new_session_id, "bash"),
+ Some(true),
+ "the new session must retain the Agent's configured policy"
+ );
+ drop(agent);
+ assert_eq!(
+ crate::tool::session_tool_policy_allows_tool_for_test(&new_session_id, "bash"),
+ None
+ );
+}
+
#[async_trait]
impl Provider for DelayedProvider {
async fn complete(
@@ -104,6 +269,61 @@ impl Provider for NativeAutoCompactionProvider {
}
}
+#[async_trait]
+impl Provider for NativeCompactionStreamProvider {
+ async fn complete(
+ &self,
+ _messages: &[Message],
+ _tools: &[ToolDefinition],
+ _system: &str,
+ _resume_session_id: Option<&str>,
+ ) -> Result {
+ let (tx, rx) = tokio_mpsc::channel::>(4);
+ tokio::spawn(async move {
+ // Response usage is deliberately far below the provider-reported
+ // pre-compaction size so a regression that relabels usage as
+ // `pre_tokens` is caught (#1178).
+ let _ = tx
+ .send(Ok(StreamEvent::TokenUsage {
+ input_tokens: Some(24_000),
+ output_tokens: Some(10),
+ cache_read_input_tokens: None,
+ cache_creation_input_tokens: None,
+ }))
+ .await;
+ let _ = tx
+ .send(Ok(StreamEvent::Compaction {
+ trigger: "openai_native".to_string(),
+ pre_tokens: Some(80_000),
+ openai_encrypted_content: Some("enc_native_test".to_string()),
+ }))
+ .await;
+ let _ = tx
+ .send(Ok(StreamEvent::MessageEnd {
+ stop_reason: Some("end_turn".to_string()),
+ }))
+ .await;
+ });
+ Ok(Box::pin(ReceiverStream::new(rx)))
+ }
+
+ fn name(&self) -> &str {
+ "openai"
+ }
+
+ fn supports_compaction(&self) -> bool {
+ true
+ }
+
+ fn uses_jcode_compaction(&self) -> bool {
+ false
+ }
+
+ fn fork(&self) -> Arc {
+ Arc::new(Self)
+ }
+}
+
#[test]
fn tool_output_to_content_blocks_preserves_labeled_images() {
let output = ToolOutput::new("Image ready").with_labeled_image(
@@ -145,6 +365,38 @@ fn tool_output_to_content_blocks_preserves_labeled_images() {
}
}
+#[tokio::test]
+async fn queued_soft_interrupt_images_are_injected_as_image_blocks() {
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ let _guard = crate::storage::lock_test_env();
+ let mut agent = Agent::new(provider, registry);
+
+ agent.queue_soft_interrupt(
+ "look at this".to_string(),
+ vec![("image/png".to_string(), "ZmFrZQ==".to_string())],
+ false,
+ SoftInterruptSource::User,
+ );
+ let injected = agent.inject_soft_interrupts();
+
+ assert_eq!(injected.len(), 1);
+ let message = agent
+ .session
+ .messages
+ .last()
+ .expect("soft interrupt should append a user message");
+ assert!(matches!(
+ &message.content[0],
+ ContentBlock::Image { media_type, data }
+ if media_type == "image/png" && data == "ZmFrZQ=="
+ ));
+ assert!(matches!(
+ &message.content[1],
+ ContentBlock::Text { text, .. } if text == "look at this"
+ ));
+}
+
#[tokio::test]
async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() {
let _guard = crate::storage::lock_test_env();
@@ -169,7 +421,7 @@ async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() {
let keepalive_deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < keepalive_deadline {
match tokio::time::timeout(Duration::from_secs(1), rx.recv()).await {
- Ok(Some(ServerEvent::Pong { id })) => {
+ Ok(Some(ServerEvent::Pong { id, .. })) => {
assert_eq!(id, STREAM_KEEPALIVE_PONG_ID);
saw_keepalive = true;
break;
@@ -198,7 +450,7 @@ async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() {
saw_text = true;
break;
}
- Ok(Some(ServerEvent::Pong { id })) => {
+ Ok(Some(ServerEvent::Pong { id, .. })) => {
assert_eq!(id, STREAM_KEEPALIVE_PONG_ID);
}
Ok(Some(_)) => {}
@@ -216,6 +468,51 @@ async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() {
task.await.unwrap().unwrap();
}
+#[tokio::test]
+async fn run_turn_streaming_mpsc_emits_native_compaction_for_client_cache_reset() {
+ let _guard = crate::storage::lock_test_env();
+ let provider: Arc = Arc::new(NativeCompactionStreamProvider);
+ let registry = Registry::new(provider.clone()).await;
+ let mut agent = Agent::new(provider, registry);
+ agent.add_message(
+ Role::User,
+ vec![ContentBlock::Text {
+ text: "compact this".to_string(),
+ cache_control: None,
+ }],
+ );
+
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+ agent.run_turn_streaming_mpsc(tx).await.unwrap();
+
+ let mut saw_native_compaction = false;
+ while let Ok(event) = rx.try_recv() {
+ if let ServerEvent::Compaction {
+ trigger,
+ pre_tokens,
+ messages_compacted,
+ ..
+ } = event
+ {
+ assert_eq!(trigger, "openai_native");
+ assert_eq!(
+ pre_tokens,
+ Some(80_000),
+ "remote compaction must forward the provider's pre-compaction count"
+ );
+ assert!(
+ messages_compacted.is_some_and(|count| count > 0),
+ "native compaction should report a non-empty compacted prefix"
+ );
+ saw_native_compaction = true;
+ }
+ }
+ assert!(
+ saw_native_compaction,
+ "native provider compaction must reach clients so they clear KV baselines"
+ );
+}
+
/// Provider that transparently switches its model mid-stream, mimicking the
/// Anthropic retired-model fallback (`claude-fable-5` -> `claude-opus-4-8`).
struct MidStreamModelSwitchProvider {
@@ -592,6 +889,15 @@ async fn gmail_is_exposed_by_default_and_can_be_explicitly_disabled() {
let tool_names = agent.tool_names().await;
let tool_name = "gmail";
+ assert!(
+ tool_names.iter().any(|name| name == "jcode_docs"),
+ "jcode_docs must be model-visible in regular sessions"
+ );
+ assert!(
+ !tool_names.iter().any(|name| name == "selfdev"),
+ "selfdev must not be model-visible in regular sessions"
+ );
+
assert!(
definitions
.iter()
@@ -662,6 +968,7 @@ fn seed_transient_session_state(agent: &mut Agent) {
agent.push_alert("pending alert".to_string());
agent.queue_soft_interrupt(
"queued interrupt".to_string(),
+ Vec::new(),
true,
SoftInterruptSource::User,
);
@@ -755,6 +1062,38 @@ async fn restore_session_resets_runtime_interrupt_and_queue_state() {
assert!(agent.locked_tools.is_none());
}
+#[tokio::test]
+async fn explicit_provider_pin_is_persisted_and_reapplied_on_restore() {
+ let _guard = crate::storage::lock_test_env();
+ let provider = Arc::new(ExplicitPinProvider::new("z-ai/glm-5.2"));
+ let provider_dyn: Arc = provider.clone();
+ let registry = Registry::new(provider_dyn.clone()).await;
+ let mut agent = Agent::new(provider_dyn, registry);
+
+ agent
+ .set_model("z-ai/glm-5.2@Novita")
+ .expect("set explicitly pinned model");
+ assert_eq!(agent.provider_model(), "z-ai/glm-5.2@Novita");
+ let persisted = crate::session::Session::load(agent.session_id()).expect("load saved session");
+ assert_eq!(persisted.model.as_deref(), Some("z-ai/glm-5.2@Novita"));
+
+ let restored_provider = Arc::new(ExplicitPinProvider::new("other/model"));
+ let restored_provider_dyn: Arc = restored_provider.clone();
+ let restored_registry = Registry::new(restored_provider_dyn.clone()).await;
+ let restored_agent =
+ Agent::new_with_session(restored_provider_dyn, restored_registry, persisted, None);
+
+ assert_eq!(
+ restored_provider
+ .set_model_requests
+ .lock()
+ .unwrap()
+ .as_slice(),
+ ["openrouter:z-ai/glm-5.2@Novita"]
+ );
+ assert_eq!(restored_agent.provider_model(), "z-ai/glm-5.2@Novita");
+}
+
#[tokio::test]
async fn restore_session_rehydrates_injected_memory_ids() {
let _guard = crate::storage::lock_test_env();
@@ -929,6 +1268,7 @@ async fn mark_closed_persists_soft_interrupts_for_restore_after_reload() {
agent.session.save().expect("save active session");
agent.queue_soft_interrupt(
"resume me after reload".to_string(),
+ Vec::new(),
true,
SoftInterruptSource::System,
);
@@ -1012,6 +1352,200 @@ impl crate::tool::Tool for FakeMcpTool {
}
}
+struct VerboseFakeMcpTool {
+ name: String,
+ description: String,
+}
+
+#[async_trait]
+impl crate::tool::Tool for VerboseFakeMcpTool {
+ fn name(&self) -> &str {
+ &self.name
+ }
+ fn description(&self) -> &str {
+ &self.description
+ }
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "properties": {"value": {"type": "string"}}
+ })
+ }
+ async fn execute(
+ &self,
+ _input: serde_json::Value,
+ _ctx: crate::tool::ToolContext,
+ ) -> anyhow::Result {
+ Ok(ToolOutput::new("ok"))
+ }
+}
+
+async fn register_fake_deferred_mcp_surface(registry: &Registry) {
+ for name in ["mcp_search", "mcp_call"] {
+ registry
+ .register(
+ name.to_string(),
+ Arc::new(FakeMcpTool {
+ name: name.to_string(),
+ }) as Arc,
+ )
+ .await;
+ }
+}
+
+async fn agent_with_fake_mcp_surface(mode: crate::config::McpToolsMode, threshold: usize) -> Agent {
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ register_fake_deferred_mcp_surface(®istry).await;
+ registry
+ .register(
+ "mcp__test__verbose".to_string(),
+ Arc::new(VerboseFakeMcpTool {
+ name: "verbose".to_string(),
+ description: "large MCP definition ".repeat(32),
+ }) as Arc,
+ )
+ .await;
+ let mut agent = Agent::new(provider, registry);
+ agent.mcp_tools_mode = mode;
+ agent.mcp_tools_token_threshold = threshold;
+ agent
+}
+
+#[tokio::test]
+async fn mcp_exposure_modes_select_eager_or_fixed_definitions() {
+ let _guard = crate::storage::lock_test_env();
+
+ let mut eager = agent_with_fake_mcp_surface(crate::config::McpToolsMode::Eager, 0).await;
+ let eager_names: Vec = eager
+ .tool_definitions()
+ .await
+ .into_iter()
+ .map(|tool| tool.name)
+ .collect();
+ assert!(eager_names.iter().any(|name| name == "mcp__test__verbose"));
+ assert!(!eager_names.iter().any(|name| name == "mcp_search"));
+ assert!(!eager_names.iter().any(|name| name == "mcp_call"));
+
+ let mut deferred =
+ agent_with_fake_mcp_surface(crate::config::McpToolsMode::Deferred, usize::MAX).await;
+ let deferred_names: Vec = deferred
+ .tool_definitions()
+ .await
+ .into_iter()
+ .map(|tool| tool.name)
+ .collect();
+ assert!(!deferred_names.iter().any(|name| name.starts_with("mcp__")));
+ assert!(deferred_names.iter().any(|name| name == "mcp_search"));
+ assert!(deferred_names.iter().any(|name| name == "mcp_call"));
+
+ let mut auto_eager =
+ agent_with_fake_mcp_surface(crate::config::McpToolsMode::Auto, usize::MAX).await;
+ let auto_eager_names: Vec = auto_eager
+ .tool_definitions()
+ .await
+ .into_iter()
+ .map(|tool| tool.name)
+ .collect();
+ assert!(
+ auto_eager_names
+ .iter()
+ .any(|name| name == "mcp__test__verbose")
+ );
+
+ let mut auto_deferred = agent_with_fake_mcp_surface(crate::config::McpToolsMode::Auto, 1).await;
+ let auto_deferred_names: Vec = auto_deferred
+ .tool_definitions()
+ .await
+ .into_iter()
+ .map(|tool| tool.name)
+ .collect();
+ assert!(
+ !auto_deferred_names
+ .iter()
+ .any(|name| name.starts_with("mcp__"))
+ );
+ assert!(auto_deferred_names.iter().any(|name| name == "mcp_search"));
+ assert!(auto_deferred_names.iter().any(|name| name == "mcp_call"));
+ let stable_auto_names: Vec = auto_deferred
+ .tool_definitions()
+ .await
+ .into_iter()
+ .map(|tool| tool.name)
+ .collect();
+ assert_eq!(auto_deferred_names, stable_auto_names);
+ assert!(auto_deferred.mcp_late_register_resolved);
+}
+
+#[tokio::test]
+async fn deferred_mcp_surface_ignores_late_per_tool_registration() {
+ let _guard = crate::storage::lock_test_env();
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ register_fake_deferred_mcp_surface(®istry).await;
+ let mut agent = Agent::new(provider, registry);
+ agent.mcp_tools_mode = crate::config::McpToolsMode::Deferred;
+
+ let before: Vec = agent
+ .tool_definitions()
+ .await
+ .into_iter()
+ .map(|tool| tool.name)
+ .collect();
+ agent
+ .registry
+ .register(
+ "mcp__late__tool".to_string(),
+ Arc::new(FakeMcpTool {
+ name: "late".to_string(),
+ }) as Arc,
+ )
+ .await;
+ let after: Vec = agent
+ .tool_definitions()
+ .await
+ .into_iter()
+ .map(|tool| tool.name)
+ .collect();
+
+ assert_eq!(
+ before, after,
+ "fixed deferred surface must stay cache-stable"
+ );
+ assert!(agent.mcp_late_register_resolved);
+ assert!(!after.iter().any(|name| name.starts_with("mcp__")));
+}
+
+#[tokio::test]
+async fn auto_mode_rechecks_late_mcp_definitions_before_deferring() {
+ let _guard = crate::storage::lock_test_env();
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ register_fake_deferred_mcp_surface(®istry).await;
+ let mut agent = Agent::new(provider, registry);
+ agent.mcp_tools_mode = crate::config::McpToolsMode::Auto;
+ agent.mcp_tools_token_threshold = 1;
+
+ let before = agent.tool_definitions().await;
+ assert!(!before.iter().any(|tool| tool.name == "mcp_search"));
+ agent
+ .registry
+ .register(
+ "mcp__late__large".to_string(),
+ Arc::new(VerboseFakeMcpTool {
+ name: "large".to_string(),
+ description: "late large definition ".repeat(32),
+ }) as Arc,
+ )
+ .await;
+
+ let after = agent.tool_definitions().await;
+ assert!(after.iter().any(|tool| tool.name == "mcp_search"));
+ assert!(after.iter().any(|tool| tool.name == "mcp_call"));
+ assert!(!after.iter().any(|tool| tool.name.starts_with("mcp__")));
+ assert!(agent.mcp_late_register_resolved);
+}
+
/// Reproduction for #206: MCP tools that register on the registry *after* the
/// first turn locks the tool snapshot never reach the provider, because
/// `tool_definitions()` returns the frozen `locked_tools` snapshot and the only
@@ -1184,6 +1718,68 @@ async fn tool_snapshot_is_stable_without_new_mcp_tools() {
);
}
+#[test]
+fn empty_post_tool_response_gets_more_than_one_retry() {
+ // Regression guard for the Claude Opus 5 benchmark incident. A provider can
+ // return an empty response immediately after tool results; that is a
+ // transient hiccup, not a finished task. With only one retry allowed, a
+ // single empty response (observed once in 43 turns) ended a 20-hour agent
+ // run with the work half-done and the submission unoptimized.
+ assert!(
+ Agent::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS > 1,
+ "a single retry lets one transient empty response end a long run"
+ );
+ // Bounded, so a genuinely finished agent still exits instead of looping.
+ assert!(Agent::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS <= 10);
+}
+
+#[test]
+fn output_budget_truncation_requests_a_continuation() {
+ // Regression guard for the Claude Opus 5 benchmark incident. A turn cut off
+ // by the output budget reports stop_reason=max_tokens and can contain zero
+ // tool calls, which otherwise looks exactly like a finished turn. The agent
+ // must treat it as incomplete and continue rather than ending the run.
+ assert!(Agent::should_continue_after_stop_reason("max_tokens"));
+ assert!(Agent::should_continue_after_stop_reason("MAX_TOKENS"));
+ assert!(Agent::should_continue_after_stop_reason(" max_tokens "));
+ assert!(Agent::should_continue_after_stop_reason(
+ "max_output_tokens"
+ ));
+ assert!(Agent::should_continue_after_stop_reason("length"));
+ assert!(Agent::should_continue_after_stop_reason("truncated"));
+ assert!(Agent::should_continue_after_stop_reason("incomplete"));
+
+ // Normal completions must not trigger a continuation loop.
+ assert!(!Agent::should_continue_after_stop_reason("end_turn"));
+ assert!(!Agent::should_continue_after_stop_reason("tool_use"));
+ assert!(!Agent::should_continue_after_stop_reason("stop"));
+ // An absent reason is the pre-fix wire behaviour: it cannot be recovered
+ // from, which is precisely why MessageEnd must forward the real reason.
+ assert!(!Agent::should_continue_after_stop_reason(""));
+}
+
+#[test]
+fn stranded_tool_use_stop_is_detected() {
+ // Second half of the Opus 5 DeepSWE incident: the provider reported
+ // stop_reason="tool_use" while the parsed tool-call list was empty, so the
+ // turn loop had nothing to execute and broke out mid-task, discarding every
+ // uncommitted edit. `tool_use` is a normal completion reason, so
+ // `should_continue_after_stop_reason` must keep rejecting it; the stranded
+ // case is only recoverable when it is paired with zero tool calls, which is
+ // exactly what this predicate is for.
+ assert!(Agent::is_stranded_tool_use_stop(Some("tool_use")));
+ assert!(Agent::is_stranded_tool_use_stop(Some("TOOL_USE")));
+ assert!(Agent::is_stranded_tool_use_stop(Some(" tool_use ")));
+
+ assert!(!Agent::is_stranded_tool_use_stop(Some("end_turn")));
+ assert!(!Agent::is_stranded_tool_use_stop(Some("max_tokens")));
+ assert!(!Agent::is_stranded_tool_use_stop(Some("")));
+ assert!(!Agent::is_stranded_tool_use_stop(None));
+ // Must stay disjoint from the truncation path so a turn never takes both
+ // continuation branches for one stop reason.
+ assert!(!Agent::should_continue_after_stop_reason("tool_use"));
+}
+
#[test]
fn guardrail_stop_reason_detection() {
assert!(Agent::is_guardrail_stop_reason(Some("refusal")));
@@ -1199,6 +1795,63 @@ fn guardrail_stop_reason_detection() {
assert!(!Agent::is_guardrail_stop_reason(None));
}
+#[test]
+fn fable_guardrail_reconsideration_is_narrow_and_bounded() {
+ assert!(Agent::should_reconsider_fable_guardrail(
+ "claude-fable-5",
+ Some("refusal"),
+ 0,
+ 1,
+ ));
+ assert!(Agent::should_reconsider_fable_guardrail(
+ "CLAUDE-FABLE-5-20260801",
+ Some("content_filter"),
+ 0,
+ 1,
+ ));
+ assert!(Agent::should_reconsider_fable_guardrail(
+ "claude-fable-5",
+ Some("refusal"),
+ 1,
+ 3,
+ ));
+ assert!(Agent::should_reconsider_fable_guardrail(
+ "claude-fable-5",
+ Some("refusal"),
+ 2,
+ 3,
+ ));
+ assert!(!Agent::should_reconsider_fable_guardrail(
+ "claude-fable-5",
+ Some("refusal"),
+ 3,
+ 3,
+ ));
+ assert!(!Agent::should_reconsider_fable_guardrail(
+ "claude-fable-5",
+ Some("end_turn"),
+ 0,
+ 1,
+ ));
+ assert!(!Agent::should_reconsider_fable_guardrail(
+ "claude-opus-5",
+ Some("refusal"),
+ 0,
+ 1,
+ ));
+}
+
+#[test]
+fn fable_guardrail_prompt_suite_is_distinct_and_safety_preserving() {
+ let prompts = Agent::FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS;
+ assert_eq!(prompts.len(), 3);
+ assert_ne!(prompts[0], prompts[1]);
+ assert_ne!(prompts[1], prompts[2]);
+ assert!(prompts[0].contains("full context"));
+ assert!(prompts[1].contains("safe portions"));
+ assert!(prompts[2].contains("Do not weaken a refusal"));
+}
+
#[test]
fn guardrail_notice_for_refusal_stop() {
let notice = Agent::provider_guardrail_notice(Some("refusal"), true, true)
@@ -1208,7 +1861,6 @@ fn guardrail_notice_for_refusal_stop() {
"notice should name the stop reason: {notice}"
);
assert!(notice.to_lowercase().contains("guardrail"));
-
// Guardrail stop with visible text still surfaces (partial output then refusal).
assert!(Agent::provider_guardrail_notice(Some("refusal"), false, false).is_some());
}
@@ -1220,7 +1872,6 @@ fn guardrail_notice_for_silent_empty_turn() {
.expect("empty visible output must produce a notice");
assert!(notice.contains("internal reasoning"), "{notice}");
assert!(notice.contains("end_turn"), "{notice}");
-
// Unknown stop reason, empty output, no reasoning.
let notice = Agent::provider_guardrail_notice(None, true, false)
.expect("empty visible output must produce a notice");
@@ -1234,3 +1885,291 @@ fn guardrail_notice_absent_for_normal_turns() {
assert!(Agent::provider_guardrail_notice(Some("end_turn"), false, false).is_none());
assert!(Agent::provider_guardrail_notice(None, false, true).is_none());
}
+
+#[test]
+fn empty_turn_log_event_separates_guardrails_from_transient_empties() {
+ assert_eq!(
+ Agent::empty_turn_log_event(Some("refusal")),
+ "PROVIDER_GUARDRAIL"
+ );
+ assert_eq!(
+ Agent::empty_turn_log_event(Some("content_filter")),
+ "PROVIDER_GUARDRAIL"
+ );
+ assert_eq!(
+ Agent::empty_turn_log_event(Some("stop")),
+ "PROVIDER_EMPTY_RESPONSE"
+ );
+ assert_eq!(Agent::empty_turn_log_event(None), "PROVIDER_EMPTY_RESPONSE");
+}
+
+#[test]
+fn guardrail_notice_for_transient_empty_does_not_blame_content_filter() {
+ let notice = Agent::provider_guardrail_notice(Some("stop"), true, false)
+ .expect("empty visible output must produce a notice");
+ assert!(
+ !notice.contains("usually a provider-side guardrail"),
+ "transient empty responses must not be blamed on a guardrail: {notice}"
+ );
+ assert!(notice.contains("empty response"), "{notice}");
+}
+
+#[tokio::test]
+async fn empty_post_tool_response_is_retried_in_shared_helper() {
+ let _guard = crate::storage::lock_test_env();
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ let mut agent = Agent::new(provider, registry);
+
+ let mut attempts = 0u32;
+ // Empty response right after tool results: inject continuation.
+ let retried = agent
+ .maybe_continue_empty_post_tool_response(true, true, Some("stop"), &mut attempts)
+ .expect("helper must not error");
+ assert!(retried);
+ assert_eq!(attempts, 1);
+ let recovery = agent
+ .session
+ .messages
+ .last()
+ .expect("recovery instruction must be persisted");
+ assert_eq!(recovery.role, Role::User);
+ assert!(
+ recovery
+ .content
+ .iter()
+ .find_map(|block| match block {
+ ContentBlock::Text { text, .. } => Some(text.as_str()),
+ _ => None,
+ })
+ .is_some_and(|text| text.starts_with("")),
+ "synthetic recovery instruction must be hidden from the transcript"
+ );
+
+ // A guardrail refusal is deliberate and must not be retried.
+ let retried = agent
+ .maybe_continue_empty_post_tool_response(true, true, Some("refusal"), &mut attempts)
+ .expect("helper must not error");
+ assert!(!retried);
+
+ // Visible output or no recent tool result: no retry.
+ assert!(
+ !agent
+ .maybe_continue_empty_post_tool_response(false, true, Some("stop"), &mut attempts)
+ .unwrap()
+ );
+ assert!(
+ !agent
+ .maybe_continue_empty_post_tool_response(true, false, Some("stop"), &mut attempts)
+ .unwrap()
+ );
+
+ // Retry budget is bounded.
+ attempts = Agent::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS;
+ assert!(
+ !agent
+ .maybe_continue_empty_post_tool_response(true, true, Some("stop"), &mut attempts)
+ .unwrap()
+ );
+}
+
+include!("agent_tests/retention_readiness.rs");
+
+/// Provider that reproduces the DeepSWE Opus 5 incident: the first response
+/// ends with `stop_reason: "tool_use"` while carrying no tool-use block at all,
+/// which is what happens when an unrecognized content block is dropped from the
+/// stream. The second response is a normal completion, so a correct agent
+/// recovers and this provider's queue is exhausted.
+#[derive(Clone, Default)]
+struct StrandedToolUseProvider {
+ calls: Arc>,
+}
+
+#[async_trait]
+impl Provider for StrandedToolUseProvider {
+ async fn complete(
+ &self,
+ _messages: &[Message],
+ _tools: &[ToolDefinition],
+ _system: &str,
+ _resume_session_id: Option<&str>,
+ ) -> Result {
+ let call = {
+ let mut guard = self.calls.lock().unwrap();
+ *guard += 1;
+ *guard
+ };
+ let (tx, rx) = tokio_mpsc::channel::>(8);
+ tokio::spawn(async move {
+ if call == 1 {
+ let _ = tx
+ .send(Ok(StreamEvent::TextDelta("working on it".to_string())))
+ .await;
+ // No ToolUseStart: the tool block was lost, yet the provider
+ // still reports that it stopped in order to call a tool.
+ let _ = tx
+ .send(Ok(StreamEvent::MessageEnd {
+ stop_reason: Some("tool_use".to_string()),
+ }))
+ .await;
+ } else {
+ let _ = tx
+ .send(Ok(StreamEvent::TextDelta("all done".to_string())))
+ .await;
+ let _ = tx
+ .send(Ok(StreamEvent::MessageEnd {
+ stop_reason: Some("end_turn".to_string()),
+ }))
+ .await;
+ }
+ });
+ Ok(Box::pin(ReceiverStream::new(rx)))
+ }
+
+ fn name(&self) -> &str {
+ "stranded-tool-use"
+ }
+
+ fn fork(&self) -> Arc {
+ Arc::new(self.clone())
+ }
+}
+
+/// End-to-end guard for the incident. Before the fix the agent took the
+/// "no tool calls" branch and ended the turn on the very first response, so a
+/// benchmark trial stopped mid-task and its uncommitted work was never
+/// captured. The agent must instead ask the model to continue, which shows up
+/// as a second provider call and a final turn that ends normally.
+#[tokio::test]
+async fn stranded_tool_use_stop_continues_instead_of_ending_the_turn() {
+ let _guard = crate::storage::lock_test_env();
+ let stranded = StrandedToolUseProvider::default();
+ let calls = stranded.calls.clone();
+ let provider: Arc = Arc::new(stranded);
+ let registry = Registry::new(provider.clone()).await;
+ let mut agent = Agent::new(provider, registry);
+
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+ agent
+ .run_once_streaming_mpsc("do the task", Vec::new(), None, tx)
+ .await
+ .expect("turn should complete");
+
+ let mut text = String::new();
+ while let Ok(event) = rx.try_recv() {
+ if let ServerEvent::TextDelta { text: delta } = event {
+ text.push_str(&delta);
+ }
+ }
+
+ assert_eq!(
+ *calls.lock().unwrap(),
+ 2,
+ "a tool_use stop with no tool call must trigger exactly one continuation request"
+ );
+ assert!(
+ text.contains("all done"),
+ "the recovered turn must deliver the model's real completion, got {text:?}"
+ );
+}
+
+#[derive(Clone, Default)]
+struct FableGuardrailProvider {
+ calls: Arc>,
+ prompts_seen: Arc>>,
+}
+
+#[async_trait]
+impl Provider for FableGuardrailProvider {
+ async fn complete(
+ &self,
+ messages: &[Message],
+ _tools: &[ToolDefinition],
+ _system: &str,
+ _resume_session_id: Option<&str>,
+ ) -> Result {
+ let call = {
+ let mut calls = self.calls.lock().unwrap();
+ *calls += 1;
+ *calls
+ };
+ if call > 1 {
+ let prompt = messages
+ .last()
+ .map(message_text)
+ .unwrap_or_default()
+ .to_string();
+ self.prompts_seen.lock().unwrap().push(prompt);
+ }
+
+ let (tx, rx) = tokio_mpsc::channel::>(4);
+ tokio::spawn(async move {
+ if call <= 3 {
+ let _ = tx
+ .send(Ok(StreamEvent::MessageEnd {
+ stop_reason: Some("refusal".to_string()),
+ }))
+ .await;
+ } else {
+ let _ = tx
+ .send(Ok(StreamEvent::TextDelta(
+ "Reconsidered and completed safely".to_string(),
+ )))
+ .await;
+ let _ = tx
+ .send(Ok(StreamEvent::MessageEnd {
+ stop_reason: Some("end_turn".to_string()),
+ }))
+ .await;
+ }
+ });
+ Ok(Box::pin(ReceiverStream::new(rx)))
+ }
+
+ fn name(&self) -> &str {
+ "anthropic"
+ }
+
+ fn model(&self) -> String {
+ "claude-fable-5".to_string()
+ }
+
+ fn fork(&self) -> Arc {
+ Arc::new(self.clone())
+ }
+}
+
+#[tokio::test]
+async fn fable_guardrail_reconsideration_recovers_the_streaming_turn() {
+ let _guard = crate::storage::lock_test_env();
+ let fable = FableGuardrailProvider::default();
+ let calls = fable.calls.clone();
+ let prompts_seen = fable.prompts_seen.clone();
+ let provider: Arc = Arc::new(fable);
+ let registry = Registry::new(provider.clone()).await;
+ let mut agent = Agent::new(provider, registry);
+
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+ agent
+ .run_once_streaming_mpsc("do this ordinary coding task", Vec::new(), None, tx)
+ .await
+ .expect("turn should recover from the guardrail");
+
+ let mut text = String::new();
+ while let Ok(event) = rx.try_recv() {
+ if let ServerEvent::TextDelta { text: delta } = event {
+ text.push_str(&delta);
+ }
+ }
+
+ assert_eq!(*calls.lock().unwrap(), 4);
+ let prompts = prompts_seen.lock().unwrap();
+ assert_eq!(prompts.len(), 3);
+ assert!(prompts[0].contains("concrete harmful action"));
+ assert!(prompts[1].contains("safe portions"));
+ assert!(prompts[2].contains("final, independent policy check"));
+ assert!(
+ text.contains("Reconsidered and completed safely"),
+ "{text:?}"
+ );
+}
diff --git a/crates/jcode-app-core/src/agent_tests/concurrency.rs b/crates/jcode-app-core/src/agent_tests/concurrency.rs
new file mode 100644
index 0000000000..a42fae479a
--- /dev/null
+++ b/crates/jcode-app-core/src/agent_tests/concurrency.rs
@@ -0,0 +1,114 @@
+use super::*;
+
+// These test the real Agent lifecycle wiring. Telemetry-core separately tests
+// live OS leases and process crashes. Never send synthetic Agent events to the
+// production endpoint while exercising construction/clear/resume here.
+struct IsolatedEnv {
+ _home: tempfile::TempDir,
+ previous_home: Option,
+ previous_opt_out: Option,
+}
+
+impl IsolatedEnv {
+ fn new() -> Self {
+ let home = tempfile::tempdir().unwrap();
+ let previous_home = std::env::var_os("JCODE_HOME");
+ let previous_opt_out = std::env::var_os("JCODE_NO_TELEMETRY");
+ crate::env::set_var("JCODE_HOME", home.path());
+ crate::env::set_var("JCODE_NO_TELEMETRY", "1");
+ Self {
+ _home: home,
+ previous_home,
+ previous_opt_out,
+ }
+ }
+}
+
+impl Drop for IsolatedEnv {
+ fn drop(&mut self) {
+ for (key, value) in [
+ ("JCODE_HOME", self.previous_home.take()),
+ ("JCODE_NO_TELEMETRY", self.previous_opt_out.take()),
+ ] {
+ if let Some(value) = value {
+ crate::env::set_var(key, value);
+ } else {
+ crate::env::remove_var(key);
+ }
+ }
+ }
+}
+
+fn assert_owns_current_session(agent: &Agent) {
+ let guard = agent
+ .concurrency_session
+ .as_ref()
+ .expect("Agent owns a guard");
+ assert_eq!(guard.session_id(), agent.session_id());
+ assert!(!guard.is_active(), "test explicitly opted out of telemetry");
+}
+
+#[tokio::test]
+async fn concurrency_guard_follows_clear_restore_and_close() {
+ let _lock = crate::storage::lock_test_env();
+ let _env = IsolatedEnv::new();
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ let mut agent = Agent::new(provider, registry);
+ assert_owns_current_session(&agent);
+ let original_id = agent.session_id().to_owned();
+
+ // Failed restores must not end the currently owned session.
+ assert!(
+ agent
+ .restore_session("nonexistent-concurrency-session")
+ .is_err()
+ );
+ assert_eq!(agent.session_id(), original_id);
+ assert_owns_current_session(&agent);
+
+ agent.clear();
+ assert_ne!(agent.session_id(), original_id);
+ assert_owns_current_session(&agent);
+
+ let mut restored = Session::create(
+ Some("parent-concurrency-test".to_owned()),
+ Some("Concurrency restore fixture".to_owned()),
+ );
+ restored.save().unwrap();
+ agent.restore_session(&restored.id).unwrap();
+ assert_owns_current_session(&agent);
+
+ agent.mark_closed();
+ assert!(
+ agent.concurrency_session.is_none(),
+ "retained closed agents are not live"
+ );
+ agent.mark_closed();
+ assert!(agent.concurrency_session.is_none(), "closing is idempotent");
+
+ // A retained Agent can resume after it has already been closed.
+ agent.restore_session(&restored.id).unwrap();
+ assert_owns_current_session(&agent);
+ agent.mark_crashed(Some("test".to_owned()));
+ assert!(agent.concurrency_session.is_none());
+}
+
+#[tokio::test]
+async fn concurrency_guards_belong_to_each_agent_not_the_global_telemetry_slot() {
+ let _lock = crate::storage::lock_test_env();
+ let _env = IsolatedEnv::new();
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ let mut first = Agent::new(provider.clone(), registry);
+ let child_session = Session::create(Some(first.session_id().to_owned()), None);
+ let registry = Registry::new(provider.clone()).await;
+ let second = Agent::new_with_session(provider, registry, child_session, None);
+ assert_owns_current_session(&first);
+ assert_owns_current_session(&second);
+ assert_ne!(first.session_id(), second.session_id());
+
+ first.mark_closed();
+ assert!(first.concurrency_session.is_none());
+ assert_owns_current_session(&second);
+}
diff --git a/crates/jcode-app-core/src/agent_tests/concurrency_construction.rs b/crates/jcode-app-core/src/agent_tests/concurrency_construction.rs
new file mode 100644
index 0000000000..b17a379140
--- /dev/null
+++ b/crates/jcode-app-core/src/agent_tests/concurrency_construction.rs
@@ -0,0 +1,83 @@
+use super::*;
+
+struct IsolatedTelemetryEnv {
+ _home: tempfile::TempDir,
+ previous: Vec<(&'static str, Option)>,
+}
+
+impl IsolatedTelemetryEnv {
+ fn new() -> Self {
+ let home = tempfile::tempdir().unwrap();
+ let previous = ["JCODE_HOME", "JCODE_NO_TELEMETRY"]
+ .into_iter()
+ .map(|key| (key, std::env::var_os(key)))
+ .collect();
+ crate::env::set_var("JCODE_HOME", home.path());
+ crate::env::set_var("JCODE_NO_TELEMETRY", "1");
+ Self {
+ _home: home,
+ previous,
+ }
+ }
+}
+
+impl Drop for IsolatedTelemetryEnv {
+ fn drop(&mut self) {
+ for (key, value) in self.previous.drain(..) {
+ match value {
+ Some(value) => crate::env::set_var(key, value),
+ None => crate::env::remove_var(key),
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn provisional_connection_does_not_track_until_logical_ownership_commits() {
+ let _lock = crate::storage::lock_test_env();
+ let _env = IsolatedTelemetryEnv::new();
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ let mut agent = Agent::new_provisional_with_initial_working_dir(provider, registry, None);
+ assert!(
+ !agent.has_concurrency_tracking(),
+ "a viewer placeholder is not a live logical session"
+ );
+ agent.activate_concurrency_tracking();
+ assert!(
+ agent.has_concurrency_tracking(),
+ "idle committed sessions must count before their first turn"
+ );
+ let first_guard = format!("{:?}", agent.concurrency_session);
+ agent.activate_concurrency_tracking();
+ assert_eq!(
+ format!("{:?}", agent.concurrency_session),
+ first_guard,
+ "repeated subscribe must not create another incarnation"
+ );
+ agent.mark_closed();
+ assert!(!agent.has_concurrency_tracking());
+}
+
+#[tokio::test]
+async fn headless_parent_is_set_before_concurrency_tracking_begins() {
+ let _lock = crate::storage::lock_test_env();
+ let _env = IsolatedTelemetryEnv::new();
+ let provider: Arc = Arc::new(NativeAutoCompactionProvider);
+ let registry = Registry::new(provider.clone()).await;
+ let child = Agent::new_with_parent_and_initial_working_dir(
+ provider.clone(),
+ registry,
+ None,
+ Some("coordinator-session".to_owned()),
+ );
+ assert_eq!(
+ child.session.parent_id.as_deref(),
+ Some("coordinator-session")
+ );
+ assert!(format!("{:?}", child.concurrency_session).contains("child: true"));
+ let registry = Registry::new(provider.clone()).await;
+ let root = Agent::new_with_parent_and_initial_working_dir(provider, registry, None, None);
+ assert!(root.session.parent_id.is_none());
+ assert!(format!("{:?}", root.concurrency_session).contains("child: false"));
+}
diff --git a/crates/jcode-app-core/src/agent_tests/retention_readiness.rs b/crates/jcode-app-core/src/agent_tests/retention_readiness.rs
new file mode 100644
index 0000000000..d306334333
--- /dev/null
+++ b/crates/jcode-app-core/src/agent_tests/retention_readiness.rs
@@ -0,0 +1,633 @@
+// Deterministic longitudinal synthetic-cohort evaluator for retention readiness.
+//
+// This deliberately does NOT claim to measure human retention. It asks whether
+// jcode has the product properties that make returning likely: a useful first
+// result, cheap re-entry, preserved context, durable state, recoverable failure,
+// and value that compounds across sessions. Real D1/D7/D30 cohort retention is
+// a separate telemetry outcome and is never synthesized here.
+//
+// The test drives the real Agent, Provider, Session persistence, and restore
+// paths through labeled D0/D1/D7 boundaries. The labels are deterministic phase
+// boundaries, not wall-clock sleeps. Run the scorecard with:
+//
+// cargo test -p jcode-app-core --lib retention_readiness -- --nocapture
+
+#[derive(Clone)]
+struct RetentionReadinessProvider {
+ fail_d7_once: std::sync::Arc,
+ transcripts: std::sync::Arc>>>,
+}
+
+struct RetentionHomeRestore(Option);
+
+impl Drop for RetentionHomeRestore {
+ fn drop(&mut self) {
+ if let Some(previous) = self.0.take() {
+ crate::env::set_var("JCODE_HOME", previous);
+ } else {
+ crate::env::remove_var("JCODE_HOME");
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RetentionFactorStatus {
+ Scored,
+ Deferred,
+ ObservedOnly,
+}
+
+struct RetentionFactor {
+ name: &'static str,
+ status: RetentionFactorStatus,
+ rationale: &'static str,
+}
+
+/// Explicit scope registry. Observed-only outcomes never enter this
+/// deterministic score. Known evidence gaps lower coverage rather than vanish.
+fn retention_factor_registry() -> [RetentionFactor; 9] {
+ use RetentionFactorStatus::{Deferred, ObservedOnly, Scored};
+ [
+ RetentionFactor {
+ name: "assistant-response first value",
+ status: Scored,
+ rationale: "D0 real Agent turn reaches a deterministic useful answer",
+ },
+ RetentionFactor {
+ name: "return friction",
+ status: Scored,
+ rationale: "D1 counts context restatement and prompts-to-value",
+ },
+ RetentionFactor {
+ name: "state continuity",
+ status: Scored,
+ rationale: "D1 disk rehydrate preserves transcript, metadata, and memory marker",
+ },
+ RetentionFactor {
+ name: "restart/failure durability",
+ status: Scored,
+ rationale: "D7 provider failure leaves the real persisted Session loadable",
+ },
+ RetentionFactor {
+ name: "failure recovery",
+ status: Scored,
+ rationale: "D7 returns to useful value after one explicit retry",
+ },
+ RetentionFactor {
+ name: "compounding context value",
+ status: Scored,
+ rationale: "D7 success is conditional on both D0 and D1 context",
+ },
+ RetentionFactor {
+ name: "tool-backed first value",
+ status: Deferred,
+ rationale: "v1 proves a useful answer, but not yet a successful real tool/file edit",
+ },
+ RetentionFactor {
+ name: "credential/provider/OS return parity",
+ status: Deferred,
+ rationale: "needs persisted credential reconstruction across a provider x OS matrix",
+ },
+ RetentionFactor {
+ name: "observed D1/D7/D30 meaningful-work retention",
+ status: ObservedOnly,
+ rationale: "requires privacy-safe real cohorts and must never be synthesized",
+ },
+ ]
+}
+
+impl RetentionReadinessProvider {
+ fn new() -> Self {
+ Self {
+ fail_d7_once: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
+ transcripts: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
+ }
+ }
+
+ fn transcript_snapshots(&self) -> Vec> {
+ self.transcripts
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .clone()
+ }
+}
+
+#[async_trait]
+impl Provider for RetentionReadinessProvider {
+ async fn complete(
+ &self,
+ messages: &[Message],
+ _tools: &[ToolDefinition],
+ _system: &str,
+ _resume_session_id: Option<&str>,
+ ) -> Result {
+ let transcript: Vec = messages
+ .iter()
+ .map(|message| message_text(message).to_string())
+ .collect();
+ self.transcripts
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .push(transcript.clone());
+
+ let latest = transcript.last().cloned().unwrap_or_default();
+ if latest.contains("D7_RECOVER")
+ && self
+ .fail_d7_once
+ .swap(false, std::sync::atomic::Ordering::SeqCst)
+ {
+ anyhow::bail!("synthetic provider outage");
+ }
+
+ let has_d0 = transcript.iter().any(|text| text.contains("D0_ACTIVATE"));
+ let has_d0_value = transcript.iter().any(|text| text.contains("VALUE_D0"));
+ let has_d1 = transcript.iter().any(|text| text.contains("D1_RETURN"));
+ let has_d1_value = transcript.iter().any(|text| text.contains("CONTINUITY_D1"));
+ let answer = if latest.contains("D0_ACTIVATE") {
+ "VALUE_D0"
+ } else if latest.contains("D1_RETURN") && has_d0 && has_d0_value {
+ "CONTINUITY_D1"
+ } else if latest.contains("D7_RECOVER") && has_d0 && has_d0_value && has_d1 && has_d1_value
+ {
+ "COMPOUNDED_D7"
+ } else {
+ "CONTEXT_LOST"
+ };
+
+ let (tx, rx) = tokio_mpsc::channel::>(4);
+ tx.send(Ok(StreamEvent::TextDelta(answer.to_string())))
+ .await
+ .expect("retention provider event receiver");
+ tx.send(Ok(StreamEvent::MessageEnd {
+ stop_reason: Some("end_turn".to_string()),
+ }))
+ .await
+ .expect("retention provider event receiver");
+ drop(tx);
+ Ok(Box::pin(ReceiverStream::new(rx)))
+ }
+
+ fn name(&self) -> &str {
+ "retention-readiness"
+ }
+
+ fn model(&self) -> String {
+ "retention-fixture-v1".to_string()
+ }
+
+ fn fork(&self) -> std::sync::Arc {
+ std::sync::Arc::new(self.clone())
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+struct RetentionDimensionScores {
+ activation: f64,
+ return_friction: f64,
+ continuity: f64,
+ durability: f64,
+ recovery: f64,
+ compounding_value: f64,
+}
+
+impl RetentionDimensionScores {
+ fn values(self) -> [(&'static str, f64, f64); 6] {
+ [
+ ("activation / first value", self.activation, 0.25),
+ ("return friction", self.return_friction, 0.20),
+ ("continuity", self.continuity, 0.20),
+ ("durability", self.durability, 0.15),
+ ("failure recovery", self.recovery, 0.10),
+ ("compounding value", self.compounding_value, 0.10),
+ ]
+ }
+
+ /// Weighted geometric mean. A zero in any dimension makes the behavioral
+ /// score zero, so perfect setup cannot compensate for catastrophic state
+ /// loss or inability to produce value.
+ fn behavioral_score(self) -> f64 {
+ self.values()
+ .into_iter()
+ .map(|(_, score, weight)| {
+ let normalized = (score.clamp(0.0, 100.0) / 100.0).max(f64::MIN_POSITIVE);
+ normalized.powf(weight)
+ })
+ .product::()
+ * 100.0
+ }
+}
+
+#[derive(Debug)]
+struct RetentionJourneyEvidence {
+ first_value: bool,
+ session_persisted: bool,
+ d1_context_available: bool,
+ restatement_steps: u32,
+ return_prompts_to_value: u32,
+ title_preserved: bool,
+ working_dir_preserved: bool,
+ memory_marker_preserved: bool,
+ history_preserved_after_failure: bool,
+ outage_surfaced: bool,
+ recovery_retries: u32,
+ recovered_value: bool,
+ compounded_value: bool,
+}
+
+fn retention_dimension_scores(e: &RetentionJourneyEvidence) -> RetentionDimensionScores {
+ let activation = 70.0 * f64::from(e.first_value) + 30.0 * f64::from(e.session_persisted);
+
+ let return_friction = (100.0
+ - e.restatement_steps as f64 * 25.0
+ - e.return_prompts_to_value.saturating_sub(1) as f64 * 15.0)
+ .max(0.0);
+
+ let continuity = 25.0
+ * [
+ e.d1_context_available,
+ e.title_preserved,
+ e.working_dir_preserved,
+ e.memory_marker_preserved,
+ ]
+ .into_iter()
+ .filter(|ok| *ok)
+ .count() as f64;
+
+ let durability =
+ 50.0 * f64::from(e.history_preserved_after_failure) + 50.0 * f64::from(e.session_persisted);
+
+ let recovery = if !e.outage_surfaced || !e.recovered_value {
+ 0.0
+ } else {
+ (100.0 - e.recovery_retries.saturating_sub(1) as f64 * 20.0).max(0.0)
+ };
+
+ let compounding_value = 100.0 * f64::from(e.compounded_value);
+
+ RetentionDimensionScores {
+ activation,
+ return_friction,
+ continuity,
+ durability,
+ recovery,
+ compounding_value,
+ }
+}
+
+#[tokio::test]
+async fn retention_readiness_scorecard() {
+ let _guard = crate::storage::lock_test_env();
+ let temp = tempfile::TempDir::new().expect("retention readiness home");
+ let previous_home = std::env::var_os("JCODE_HOME");
+ crate::env::set_var("JCODE_HOME", temp.path());
+ let _home_restore = RetentionHomeRestore(previous_home);
+
+ let provider_fixture = RetentionReadinessProvider::new();
+ let provider: std::sync::Arc = std::sync::Arc::new(provider_fixture.clone());
+ let registry = Registry::new(provider.clone()).await;
+
+ // D0: one prompt reaches a deterministic useful answer and creates durable
+ // state that a return journey can benefit from.
+ let mut d0 = Agent::new(provider.clone(), registry.clone());
+ d0.session
+ .rename_title(Some("Retention cohort project".to_string()));
+ d0.session.working_dir = Some("/synthetic/retention-project".to_string());
+ d0.session.record_memory_injection(
+ "cohort preference".to_string(),
+ "Prefer deterministic validation".to_string(),
+ 1,
+ 0,
+ vec!["retention-memory-v1".to_string()],
+ );
+ let session_id = d0.session_id().to_string();
+ let d0_answer = d0
+ .run_once_capture("D0_ACTIVATE explain this project")
+ .await
+ .expect("D0 activation turn");
+ d0.session.save().expect("persist D0 state");
+ drop(d0);
+
+ let persisted_d0 = Session::load(&session_id).expect("load D0 session");
+
+ // D1: construct a new Agent from disk, not the old in-memory object. The
+ // fixture only returns CONTINUITY_D1 when the real provider transcript still
+ // contains both D0's prompt and its useful answer.
+ let d1_provider: std::sync::Arc = std::sync::Arc::new(provider_fixture.clone());
+ let d1_registry = Registry::new(d1_provider.clone()).await;
+ let mut d1 = Agent::new_with_session(d1_provider, d1_registry, persisted_d0, None);
+ let d1_answer = d1
+ .run_once_capture("D1_RETURN continue without restating context")
+ .await
+ .expect("D1 return turn");
+ d1.session.save().expect("persist D1 state");
+ drop(d1);
+
+ // D7: inject one deterministic provider outage. The failed user turn must be
+ // durable, the process-like rehydrate must work, and one retry must produce
+ // value that depends on BOTH earlier sessions.
+ let persisted_d1 = Session::load(&session_id).expect("load D1 session");
+ let d1_message_count = persisted_d1.messages.len();
+ let title_preserved = persisted_d1.custom_title.as_deref() == Some("Retention cohort project");
+ let working_dir_preserved =
+ persisted_d1.working_dir.as_deref() == Some("/synthetic/retention-project");
+ let memory_marker_preserved = persisted_d1
+ .injected_memory_ids()
+ .iter()
+ .any(|id| id == "retention-memory-v1");
+ let d7_provider: std::sync::Arc = std::sync::Arc::new(provider_fixture.clone());
+ let d7_registry = Registry::new(d7_provider.clone()).await;
+ let mut d7 = Agent::new_with_session(d7_provider, d7_registry, persisted_d1, None);
+ let outage = d7
+ .run_once_capture("D7_RECOVER finish the longitudinal task")
+ .await;
+ drop(d7);
+
+ let after_failure = Session::load(&session_id).expect("session survives outage");
+ let history_preserved_after_failure = after_failure.messages.len() > d1_message_count
+ && after_failure.messages.iter().any(|stored| {
+ message_text(&stored.to_message()).contains("D7_RECOVER finish the longitudinal task")
+ });
+ let recovered_provider: std::sync::Arc =
+ std::sync::Arc::new(provider_fixture.clone());
+ let recovered_registry = Registry::new(recovered_provider.clone()).await;
+ let mut recovered =
+ Agent::new_with_session(recovered_provider, recovered_registry, after_failure, None);
+ let d7_answer = recovered
+ .run_once_capture("D7_RECOVER retry once")
+ .await
+ .expect("D7 recovery turn");
+ recovered
+ .session
+ .save()
+ .expect("persist recovered D7 state");
+
+ let snapshots = provider_fixture.transcript_snapshots();
+ let d1_context_available = snapshots.iter().any(|snapshot| {
+ snapshot.iter().any(|text| text.contains("D1_RETURN"))
+ && snapshot.iter().any(|text| text.contains("D0_ACTIVATE"))
+ && snapshot.iter().any(|text| text.contains("VALUE_D0"))
+ });
+
+ let evidence = RetentionJourneyEvidence {
+ first_value: d0_answer.contains("VALUE_D0"),
+ session_persisted: Session::load(&session_id).is_ok(),
+ d1_context_available: d1_context_available && d1_answer.contains("CONTINUITY_D1"),
+ restatement_steps: 0,
+ return_prompts_to_value: 1,
+ title_preserved,
+ working_dir_preserved,
+ memory_marker_preserved,
+ history_preserved_after_failure,
+ outage_surfaced: outage.is_err(),
+ recovery_retries: 1,
+ recovered_value: d7_answer.contains("COMPOUNDED_D7"),
+ compounded_value: d7_answer.contains("COMPOUNDED_D7"),
+ };
+ let scores = retention_dimension_scores(&evidence);
+ let behavioral = scores.behavioral_score();
+
+ // Coverage is reported separately and gates the headline. V1 scores six
+ // deterministic factors but deliberately defers tool-backed first value and
+ // credential/provider/OS return parity. A perfect covered journey therefore
+ // cannot be misreported as perfect evidence about retention overall.
+ let factors = retention_factor_registry();
+ let scored_factors = factors
+ .iter()
+ .filter(|factor| factor.status == RetentionFactorStatus::Scored)
+ .count();
+ let acknowledged_factors = factors
+ .iter()
+ .filter(|factor| factor.status != RetentionFactorStatus::ObservedOnly)
+ .count();
+ let evidence_coverage = scored_factors as f64 / acknowledged_factors as f64 * 100.0;
+ let coverage_adjusted = behavioral * evidence_coverage / 100.0;
+
+ println!("\n================ RETENTION READINESS (SYNTHETIC COHORT) ================");
+ println!("This is a deterministic product-readiness proxy, NOT observed user retention.\n");
+ println!("journey boundary outcome");
+ println!(
+ "D0 activate {}",
+ if evidence.first_value {
+ "useful value"
+ } else {
+ "FAIL"
+ }
+ );
+ println!(
+ "D1 return {}",
+ if evidence.d1_context_available {
+ "context continued"
+ } else {
+ "FAIL"
+ }
+ );
+ println!(
+ "D7 outage {}",
+ if evidence.outage_surfaced {
+ "surfaced"
+ } else {
+ "FAIL"
+ }
+ );
+ println!(
+ "D7 retry {}",
+ if evidence.recovered_value {
+ "recovered + compounded"
+ } else {
+ "FAIL"
+ }
+ );
+ println!("\n-- dimensions (weighted geometric mean) --");
+ for (name, score, weight) in scores.values() {
+ println!(
+ "{name:<26} {score:>5.1} / 100 weight={weight:.0}%",
+ weight = weight * 100.0
+ );
+ }
+ println!("\nBEHAVIORAL READINESS : {behavioral:>5.1} / 100");
+ println!(
+ "EVIDENCE COVERAGE : {evidence_coverage:>5.1} / 100 ({scored_factors}/{acknowledged_factors} factors)"
+ );
+ println!("COVERAGE-ADJUSTED : {coverage_adjusted:>5.1} / 100");
+ for factor in factors
+ .iter()
+ .filter(|factor| factor.status == RetentionFactorStatus::Deferred)
+ {
+ println!(
+ "Deferred : {} ({})",
+ factor.name, factor.rationale
+ );
+ }
+ println!("Observed counterpart : D1/D7/D30 meaningful-work cohorts (telemetry, separate)\n");
+
+ // Hard gates: no weighted average may hide loss of first value, continuity,
+ // durable state, recovery, or compounded context.
+ assert!(evidence.first_value, "D0 did not reach useful first value");
+ assert!(
+ evidence.session_persisted,
+ "synthetic cohort session was not durable"
+ );
+ assert!(
+ evidence.d1_context_available,
+ "D1 return lost prior context"
+ );
+ assert!(
+ evidence.title_preserved
+ && evidence.working_dir_preserved
+ && evidence.memory_marker_preserved,
+ "D1 return lost persisted metadata or memory state"
+ );
+ assert!(
+ evidence.history_preserved_after_failure,
+ "provider outage lost session history"
+ );
+ assert!(
+ evidence.outage_surfaced && evidence.recovered_value,
+ "provider outage did not recover in one retry"
+ );
+ assert!(
+ evidence.compounded_value,
+ "D7 result did not depend on D0 + D1 context"
+ );
+ assert!(
+ behavioral >= 80.0,
+ "behavioral retention readiness regressed: {behavioral:.1}"
+ );
+}
+
+#[test]
+fn retention_readiness_scoring_is_monotonic_and_non_compensating() {
+ let perfect = RetentionJourneyEvidence {
+ first_value: true,
+ session_persisted: true,
+ d1_context_available: true,
+ restatement_steps: 0,
+ return_prompts_to_value: 1,
+ title_preserved: true,
+ working_dir_preserved: true,
+ memory_marker_preserved: true,
+ history_preserved_after_failure: true,
+ outage_surfaced: true,
+ recovery_retries: 1,
+ recovered_value: true,
+ compounded_value: true,
+ };
+ let perfect_scores = retention_dimension_scores(&perfect);
+ let baseline = perfect_scores.behavioral_score();
+ let total_weight: f64 = perfect_scores
+ .values()
+ .into_iter()
+ .map(|(_, _, weight)| weight)
+ .sum();
+ assert!((total_weight - 1.0).abs() < f64::EPSILON);
+
+ let worse_cases = [
+ RetentionJourneyEvidence {
+ first_value: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ session_persisted: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ restatement_steps: 1,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ return_prompts_to_value: 2,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ d1_context_available: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ title_preserved: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ working_dir_preserved: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ memory_marker_preserved: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ history_preserved_after_failure: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ outage_surfaced: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ recovery_retries: 3,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ recovered_value: false,
+ ..perfect
+ },
+ RetentionJourneyEvidence {
+ compounded_value: false,
+ ..perfect
+ },
+ ];
+ for worse in &worse_cases {
+ assert!(
+ retention_dimension_scores(worse).behavioral_score() < baseline,
+ "making one retention factor worse must lower the score"
+ );
+ }
+
+ let catastrophic = retention_dimension_scores(&RetentionJourneyEvidence {
+ compounded_value: false,
+ ..perfect
+ });
+ assert!(
+ catastrophic.behavioral_score() < 1.0,
+ "a zero dimension must not be compensated by perfect sibling dimensions"
+ );
+}
+
+#[test]
+fn retention_readiness_factor_registry_has_explicit_scope_and_rationales() {
+ let factors = retention_factor_registry();
+ assert_eq!(
+ factors
+ .iter()
+ .filter(|f| f.status == RetentionFactorStatus::Scored)
+ .count(),
+ 6
+ );
+ assert_eq!(
+ factors
+ .iter()
+ .filter(|f| f.status == RetentionFactorStatus::Deferred)
+ .count(),
+ 2
+ );
+ assert_eq!(
+ factors
+ .iter()
+ .filter(|f| f.status == RetentionFactorStatus::ObservedOnly)
+ .count(),
+ 1
+ );
+ let mut names = std::collections::BTreeSet::new();
+ for factor in factors {
+ assert!(!factor.name.trim().is_empty());
+ assert!(
+ names.insert(factor.name),
+ "duplicate factor: {}",
+ factor.name
+ );
+ assert!(
+ !factor.rationale.trim().is_empty(),
+ "retention factor '{}' has no rationale",
+ factor.name
+ );
+ }
+}
diff --git a/crates/jcode-app-core/src/ambient/runner.rs b/crates/jcode-app-core/src/ambient/runner.rs
index 4c49ffcb9b..eb1e9e10b0 100644
--- a/crates/jcode-app-core/src/ambient/runner.rs
+++ b/crates/jcode-app-core/src/ambient/runner.rs
@@ -106,6 +106,7 @@ impl AmbientRunnerHandle {
{
q.push(SoftInterruptMessage {
content: format!("[{} message from user]\n{}", source, text),
+ images: Vec::new(),
urgent: false,
source: SoftInterruptSource::User,
});
@@ -395,7 +396,12 @@ impl AmbientRunnerHandle {
agent.restore_session(session_id)?;
let reminder = ambient::format_scheduled_session_message(item);
- let _ = agent.run_once_capture(&reminder).await?;
+ let _ = agent
+ .run_once_capture_with_display_role(
+ &reminder,
+ Some(crate::session::StoredDisplayRole::System),
+ )
+ .await?;
agent.mark_closed();
Ok(())
}
@@ -469,7 +475,12 @@ impl AmbientRunnerHandle {
}
let reminder = ambient::format_scheduled_session_message(item);
- let _ = agent.run_once_capture(&reminder).await?;
+ let _ = agent
+ .run_once_capture_with_display_role(
+ &reminder,
+ Some(crate::session::StoredDisplayRole::System),
+ )
+ .await?;
agent.mark_closed();
Ok(child_session_id)
}
@@ -876,17 +887,55 @@ impl AmbientRunnerHandle {
/// Run a single ambient cycle. Returns the cycle result.
async fn run_cycle(&self, provider: &Arc) -> anyhow::Result {
+ self.run_cycle_with_visible_launcher(provider, config().ambient.visible, || {
+ let jcode_bin =
+ std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode"));
+
+ std::process::Command::new("kitty")
+ .args([
+ "--title",
+ "🤖 jcode ambient cycle",
+ "-e",
+ &jcode_bin.to_string_lossy(),
+ "ambient",
+ "run-visible",
+ ])
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ })
+ .await
+ }
+
+ async fn run_cycle_with_visible_launcher(
+ &self,
+ provider: &Arc,
+ visible: bool,
+ launch_visible: F,
+ ) -> anyhow::Result
+ where
+ F: FnOnce() -> std::io::Result + Send,
+ {
let started_at = Utc::now();
- let visible = config().ambient.visible;
self.set_running_detail("gathering context").await;
let (system_prompt, initial_message) = self.build_cycle_context(provider).await?;
// Visible mode: spawn a full TUI instead of running headlessly
if visible {
- return self
- .run_cycle_visible(started_at, system_prompt, initial_message)
- .await;
+ match self
+ .run_cycle_visible(
+ started_at,
+ system_prompt.clone(),
+ initial_message.clone(),
+ launch_visible,
+ )
+ .await?
+ {
+ VisibleCycleOutcome::Completed(result) => return Ok(*result),
+ VisibleCycleOutcome::FallBackHeadless => {}
+ }
}
// Headless mode: run agent directly
@@ -975,12 +1024,16 @@ impl AmbientRunnerHandle {
}
/// Run a visible ambient cycle by spawning a full TUI in a kitty window.
- async fn run_cycle_visible(
+ async fn run_cycle_visible(
&self,
started_at: chrono::DateTime,
system_prompt: String,
initial_message: String,
- ) -> anyhow::Result {
+ launch_visible: F,
+ ) -> anyhow::Result
+ where
+ F: FnOnce() -> std::io::Result + Send,
+ {
use crate::ambient::VisibleCycleContext;
self.set_running_detail("launching visible TUI").await;
@@ -997,25 +1050,9 @@ impl AmbientRunnerHandle {
let _ = std::fs::remove_file(&result_path);
}
- // Find the jcode binary
- let jcode_bin =
- std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode"));
-
// Spawn kitty with `jcode ambient run-visible`
logging::info("Ambient visible: spawning kitty with jcode TUI");
- let child = std::process::Command::new("kitty")
- .args([
- "--title",
- "🤖 jcode ambient cycle",
- "-e",
- &jcode_bin.to_string_lossy(),
- "ambient",
- "run-visible",
- ])
- .stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .spawn();
+ let child = launch_visible();
match child {
Ok(mut child) => {
@@ -1035,25 +1072,29 @@ impl AmbientRunnerHandle {
crate::storage::read_json::(&result_path)
{
let _ = std::fs::remove_file(&result_path);
- return Ok(AmbientCycleResult {
- started_at,
- ended_at: Utc::now(),
- ..result
- });
+ return Ok(VisibleCycleOutcome::Completed(Box::new(
+ AmbientCycleResult {
+ started_at,
+ ended_at: Utc::now(),
+ ..result
+ },
+ )));
}
// No result file — user closed the window without end_ambient_cycle
- Ok(AmbientCycleResult {
- summary: "Visible cycle ended (user closed window)".to_string(),
- memories_modified: 0,
- compactions: 0,
- proactive_work: None,
- next_schedule: None,
- started_at,
- ended_at: Utc::now(),
- status: CycleStatus::Incomplete,
- conversation: None,
- })
+ Ok(VisibleCycleOutcome::Completed(Box::new(
+ AmbientCycleResult {
+ summary: "Visible cycle ended (user closed window)".to_string(),
+ memories_modified: 0,
+ compactions: 0,
+ proactive_work: None,
+ next_schedule: None,
+ started_at,
+ ended_at: Utc::now(),
+ status: CycleStatus::Incomplete,
+ conversation: None,
+ },
+ )))
}
Err(e) => {
logging::warn(&format!(
@@ -1061,12 +1102,17 @@ impl AmbientRunnerHandle {
e
));
// Fall back to headless mode
- Err(anyhow::anyhow!("Failed to spawn visible TUI: {}", e))
+ Ok(VisibleCycleOutcome::FallBackHeadless)
}
}
}
}
+enum VisibleCycleOutcome {
+ Completed(Box),
+ FallBackHeadless,
+}
+
// ---------------------------------------------------------------------------
#[cfg(test)]
diff --git a/crates/jcode-app-core/src/ambient/runner_tests.rs b/crates/jcode-app-core/src/ambient/runner_tests.rs
index 94146aaa0d..867e77b99c 100644
--- a/crates/jcode-app-core/src/ambient/runner_tests.rs
+++ b/crates/jcode-app-core/src/ambient/runner_tests.rs
@@ -7,6 +7,7 @@ use anyhow::Result;
use async_stream::stream;
use async_trait::async_trait;
use std::collections::VecDeque;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
@@ -121,6 +122,45 @@ async fn runner_stays_alive_to_service_schedules_when_ambient_disabled() {
let _ = task.await;
}
+async fn assert_visible_launch_error_falls_back(error_kind: std::io::ErrorKind) {
+ let _guard = crate::storage::lock_test_env();
+ let temp = tempfile::tempdir().expect("tempdir");
+ let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
+
+ let provider: Arc = Arc::new(StreamingTestProvider::default());
+ let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new()));
+ let launch_attempted = Arc::new(AtomicBool::new(false));
+ let launch_attempted_in_callback = launch_attempted.clone();
+
+ let result = runner
+ .run_cycle_with_visible_launcher(&provider, true, move || {
+ launch_attempted_in_callback.store(true, Ordering::SeqCst);
+ Err(std::io::Error::from(error_kind))
+ })
+ .await
+ .expect("failed visible launch should continue as a headless cycle");
+
+ assert!(launch_attempted.load(Ordering::SeqCst));
+ assert!(
+ result.conversation.is_some(),
+ "headless fallback should capture an agent conversation"
+ );
+ assert!(
+ result.summary.contains("forced end after 2 attempts"),
+ "headless fallback should return the headless agent result"
+ );
+}
+
+#[tokio::test]
+async fn unsupported_visible_launch_falls_back_to_headless() {
+ assert_visible_launch_error_falls_back(std::io::ErrorKind::Unsupported).await;
+}
+
+#[tokio::test]
+async fn missing_visible_launcher_falls_back_to_headless() {
+ assert_visible_launch_error_falls_back(std::io::ErrorKind::NotFound).await;
+}
+
#[tokio::test]
async fn spawn_target_creates_one_child_session_and_runs_task() {
let _guard = crate::storage::lock_test_env();
diff --git a/crates/jcode-app-core/src/notifications.rs b/crates/jcode-app-core/src/notifications.rs
index def622a657..94e41a1f23 100644
--- a/crates/jcode-app-core/src/notifications.rs
+++ b/crates/jcode-app-core/src/notifications.rs
@@ -16,6 +16,46 @@ use jcode_notify_email::{
};
pub use jcode_notify_email::{extract_permission_id, parse_permission_reply};
+/// Stable schema version for files handed to the bundled macOS notification
+/// broker. The broker ignores payloads with a newer schema instead of guessing
+/// at their meaning.
+pub const MACOS_NOTIFICATION_SCHEMA_VERSION: u32 = 1;
+
+/// The terminal route attached to a macOS turn notification.
+///
+/// `tty` is the strongest identifier available across Terminal.app and iTerm2:
+/// both expose it in their AppleScript dictionaries, so a notification click
+/// can select the exact originating tab/session. Ghostty currently exposes no
+/// supported per-surface activation API, so its route intentionally degrades to
+/// activating the application.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MacosTerminalKind {
+ AppleTerminal,
+ Iterm2,
+ Ghostty,
+ Unknown,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct MacosNotificationOrigin {
+ pub terminal: MacosTerminalKind,
+ pub bundle_id: Option,
+ pub tty: Option,
+ pub session_id: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct MacosNotificationEnvelope {
+ pub schema_version: u32,
+ pub notification_id: String,
+ pub title: String,
+ pub subtitle: Option,
+ pub body: String,
+ pub sound: Option,
+ pub origin: MacosNotificationOrigin,
+}
+
/// Notification priority levels (maps to ntfy priority header).
#[derive(Debug, Clone, Copy)]
pub enum Priority {
@@ -275,6 +315,343 @@ async fn send_ntfy(
// Desktop (cross-platform, fire-and-forget)
// ---------------------------------------------------------------------------
+#[cfg(target_os = "macos")]
+const MACOS_NOTIFICATION_BROKER_APP_NAME: &str = "Jcode Notifications.app";
+#[cfg(target_os = "macos")]
+const MACOS_NOTIFICATION_BROKER_EXECUTABLE: &str = "jcode-notification-broker";
+
+impl MacosNotificationOrigin {
+ /// Capture the terminal route for the local client which owns this process.
+ pub fn detect() -> Self {
+ let tty = controlling_tty();
+ Self::from_values(
+ &std::env::var("TERM_PROGRAM").unwrap_or_default(),
+ &std::env::var("TERM").unwrap_or_default(),
+ tty.as_deref(),
+ std::env::var("TERM_SESSION_ID").ok().as_deref(),
+ std::env::var("ITERM_SESSION_ID").ok().as_deref(),
+ std::env::var("GHOSTTY_RESOURCES_DIR").is_ok()
+ || std::env::var("GHOSTTY_BIN_DIR").is_ok(),
+ )
+ }
+
+ fn from_values(
+ term_program: &str,
+ term: &str,
+ tty: Option<&str>,
+ term_session_id: Option<&str>,
+ iterm_session_id: Option<&str>,
+ has_ghostty_env: bool,
+ ) -> Self {
+ let term_program_lower = term_program.to_ascii_lowercase();
+ let term_lower = term.to_ascii_lowercase();
+ let (terminal, bundle_id, session_id) =
+ if term_program_lower == "iterm.app" || iterm_session_id.is_some() {
+ (
+ MacosTerminalKind::Iterm2,
+ Some("com.googlecode.iterm2".to_string()),
+ iterm_session_id,
+ )
+ } else if term_program_lower == "apple_terminal" {
+ (
+ MacosTerminalKind::AppleTerminal,
+ Some("com.apple.Terminal".to_string()),
+ term_session_id,
+ )
+ } else if has_ghostty_env
+ || term_program_lower == "ghostty"
+ || term_lower.contains("ghostty")
+ {
+ (
+ MacosTerminalKind::Ghostty,
+ Some("com.mitchellh.ghostty".to_string()),
+ term_session_id,
+ )
+ } else {
+ (MacosTerminalKind::Unknown, None, term_session_id)
+ };
+
+ Self {
+ terminal,
+ bundle_id,
+ tty: tty.filter(|value| valid_tty(value)).map(str::to_string),
+ session_id: session_id
+ .filter(|value| valid_route_identifier(value))
+ .map(str::to_string),
+ }
+ }
+}
+
+fn valid_tty(value: &str) -> bool {
+ value.starts_with("/dev/tty")
+ && value.len() <= 128
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-'))
+}
+
+fn valid_route_identifier(value: &str) -> bool {
+ !value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
+}
+
+#[cfg(unix)]
+fn controlling_tty() -> Option {
+ use std::os::fd::AsRawFd;
+
+ let fd = std::io::stdin().as_raw_fd();
+ let mut buffer = vec![0 as libc::c_char; 1024];
+ // SAFETY: `buffer` is valid and writable for its full length and `fd` is a
+ // live descriptor. `ttyname_r` writes a NUL-terminated string on success.
+ let result = unsafe { libc::ttyname_r(fd, buffer.as_mut_ptr(), buffer.len()) };
+ if result != 0 {
+ return None;
+ }
+ // SAFETY: successful `ttyname_r` guarantees a NUL terminator in `buffer`.
+ let value = unsafe { std::ffi::CStr::from_ptr(buffer.as_ptr()) };
+ value.to_str().ok().map(str::to_string)
+}
+
+#[cfg(not(unix))]
+fn controlling_tty() -> Option {
+ None
+}
+
+#[cfg(target_os = "macos")]
+fn macos_notification_broker_app_path() -> Option {
+ if let Some(path) = std::env::var_os("JCODE_MACOS_NOTIFICATION_BROKER_APP") {
+ return Some(path.into());
+ }
+ dirs::home_dir().map(|home| {
+ home.join("Applications")
+ .join(MACOS_NOTIFICATION_BROKER_APP_NAME)
+ })
+}
+
+/// The durable inbox consumed by the bundled macOS broker.
+pub fn macos_notification_inbox_dir() -> Option {
+ if let Some(path) = std::env::var_os("JCODE_MACOS_NOTIFICATION_INBOX") {
+ return Some(path.into());
+ }
+ dirs::home_dir().map(|home| {
+ home.join(".jcode")
+ .join("notifications")
+ .join("macos")
+ .join("inbox")
+ })
+}
+
+/// Queue a turn notification for the bundled LSUIElement broker and wake it.
+/// Returns false when the helper is unavailable so the caller can use its
+/// terminal-native or `osascript` fallback.
+pub fn send_macos_turn_notification(
+ title: &str,
+ subtitle: Option<&str>,
+ body: &str,
+ sound: Option<&str>,
+) -> bool {
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = (title, subtitle, body, sound);
+ false
+ }
+
+ #[cfg(target_os = "macos")]
+ {
+ let Some(app_path) = macos_notification_broker_app_path() else {
+ return false;
+ };
+ let executable = app_path
+ .join("Contents")
+ .join("MacOS")
+ .join(MACOS_NOTIFICATION_BROKER_EXECUTABLE);
+ if !app_path.is_dir() || !executable.is_file() {
+ return false;
+ }
+
+ let id = next_macos_notification_id();
+ let envelope = MacosNotificationEnvelope {
+ schema_version: MACOS_NOTIFICATION_SCHEMA_VERSION,
+ notification_id: id.clone(),
+ title: title.to_string(),
+ subtitle: subtitle
+ .filter(|value| !value.trim().is_empty())
+ .map(str::to_string),
+ body: body.to_string(),
+ sound: sound
+ .filter(|value| !value.trim().is_empty())
+ .map(str::to_string),
+ origin: MacosNotificationOrigin::detect(),
+ };
+ let queued_path = match enqueue_macos_notification(&envelope) {
+ Ok(path) => path,
+ Err(error) => {
+ logging::warn(&format!("failed to queue macOS notification: {error}"));
+ return false;
+ }
+ };
+
+ match std::process::Command::new("/usr/bin/open")
+ .arg("-gj")
+ .arg(&app_path)
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ {
+ Ok(child) => {
+ reap_notification_child(child);
+ true
+ }
+ Err(error) => {
+ // The caller will send a fallback, so remove this payload rather
+ // than deliver a duplicate after a later successful launch.
+ let _ = std::fs::remove_file(queued_path);
+ logging::warn(&format!(
+ "failed to launch macOS notification broker: {error}"
+ ));
+ false
+ }
+ }
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn next_macos_notification_id() -> String {
+ use std::sync::atomic::{AtomicU64, Ordering};
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ static SEQUENCE: AtomicU64 = AtomicU64::new(0);
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ format!(
+ "jcode-turn-{timestamp}-{}-{}",
+ std::process::id(),
+ SEQUENCE.fetch_add(1, Ordering::Relaxed)
+ )
+}
+
+#[cfg(target_os = "macos")]
+fn enqueue_macos_notification(
+ envelope: &MacosNotificationEnvelope,
+) -> anyhow::Result {
+ use std::io::Write as _;
+
+ let inbox = macos_notification_inbox_dir()
+ .ok_or_else(|| anyhow::anyhow!("could not determine notification inbox"))?;
+ std::fs::create_dir_all(&inbox)?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+ std::fs::set_permissions(&inbox, std::fs::Permissions::from_mode(0o700))?;
+ }
+
+ let final_path = inbox.join(format!("{}.json", envelope.notification_id));
+ let temporary_path = inbox.join(format!(".{}.tmp", envelope.notification_id));
+ let bytes = serde_json::to_vec(envelope)?;
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&temporary_path)?;
+ file.write_all(&bytes)?;
+ file.sync_all()?;
+ std::fs::rename(&temporary_path, &final_path)?;
+ Ok(final_path)
+}
+
+fn reap_notification_child(mut child: std::process::Child) {
+ let _ = std::thread::Builder::new()
+ .name("jcode-notification-child".to_string())
+ .spawn(move || {
+ let _ = child.wait();
+ });
+}
+
+/// Build the process invocation used when a broker notification is clicked.
+/// Kept pure so routing and escaping are fully testable on non-macOS builders.
+pub fn macos_notification_activation_command(
+ origin: &MacosNotificationOrigin,
+) -> Option<(String, Vec)> {
+ fn applescript_string(value: &str) -> String {
+ value.replace('\\', "\\\\").replace('"', "\\\"")
+ }
+
+ match origin.terminal {
+ MacosTerminalKind::AppleTerminal => {
+ let tty = origin.tty.as_deref().filter(|value| valid_tty(value));
+ let script = if let Some(tty) = tty {
+ format!(
+ "tell application \"Terminal\"\nrepeat with w in windows\nrepeat with t in tabs of w\nif tty of t is \"{}\" then\nset selected tab of w to t\nset frontmost of w to true\nactivate\nreturn\nend if\nend repeat\nend repeat\nactivate\nend tell",
+ applescript_string(tty)
+ )
+ } else {
+ "tell application \"Terminal\" to activate".to_string()
+ };
+ Some((
+ "/usr/bin/osascript".to_string(),
+ vec!["-e".to_string(), script],
+ ))
+ }
+ MacosTerminalKind::Iterm2 => {
+ let tty = origin.tty.as_deref().filter(|value| valid_tty(value));
+ let script = if let Some(tty) = tty {
+ format!(
+ "tell application \"iTerm2\"\nrepeat with w in windows\nrepeat with t in tabs of w\nrepeat with s in sessions of t\nif tty of s is \"{}\" then\nselect s\nselect t\nactivate\nreturn\nend if\nend repeat\nend repeat\nend repeat\nactivate\nend tell",
+ applescript_string(tty)
+ )
+ } else {
+ "tell application \"iTerm2\" to activate".to_string()
+ };
+ Some((
+ "/usr/bin/osascript".to_string(),
+ vec!["-e".to_string(), script],
+ ))
+ }
+ MacosTerminalKind::Ghostty => Some((
+ "/usr/bin/open".to_string(),
+ vec![
+ "-b".to_string(),
+ origin
+ .bundle_id
+ .as_deref()
+ .filter(|value| *value == "com.mitchellh.ghostty")
+ .unwrap_or("com.mitchellh.ghostty")
+ .to_string(),
+ ],
+ )),
+ MacosTerminalKind::Unknown => origin.bundle_id.as_deref().and_then(|bundle_id| {
+ let safe = bundle_id.len() <= 255
+ && bundle_id
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
+ safe.then(|| {
+ (
+ "/usr/bin/open".to_string(),
+ vec!["-b".to_string(), bundle_id.to_string()],
+ )
+ })
+ }),
+ }
+}
+
+/// Activate the recorded terminal route without blocking the notification
+/// delegate's main run loop.
+pub fn activate_macos_notification_origin(origin: &MacosNotificationOrigin) {
+ let Some((program, args)) = macos_notification_activation_command(origin) else {
+ return;
+ };
+ if let Ok(child) = std::process::Command::new(program)
+ .args(args)
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ {
+ reap_notification_child(child);
+ }
+}
+
/// Send a local desktop notification without blocking.
///
/// Uses Notification Center via `osascript` on macOS and `notify-send` on
@@ -650,4 +1027,126 @@ mod tests {
let cfg = SafetyConfig::default();
let _dispatcher = NotificationDispatcher::from_config(cfg);
}
+
+ #[test]
+ fn macos_origin_detects_terminal_identifiers() {
+ let terminal = MacosNotificationOrigin::from_values(
+ "Apple_Terminal",
+ "xterm-256color",
+ Some("/dev/ttys007"),
+ Some("4F3C"),
+ None,
+ false,
+ );
+ assert_eq!(terminal.terminal, MacosTerminalKind::AppleTerminal);
+ assert_eq!(terminal.bundle_id.as_deref(), Some("com.apple.Terminal"));
+ assert_eq!(terminal.tty.as_deref(), Some("/dev/ttys007"));
+ assert_eq!(terminal.session_id.as_deref(), Some("4F3C"));
+
+ let iterm = MacosNotificationOrigin::from_values(
+ "iTerm.app",
+ "xterm-256color",
+ Some("/dev/ttys011"),
+ None,
+ Some("w0t1p0:ABC"),
+ false,
+ );
+ assert_eq!(iterm.terminal, MacosTerminalKind::Iterm2);
+ assert_eq!(iterm.session_id.as_deref(), Some("w0t1p0:ABC"));
+
+ let ghostty = MacosNotificationOrigin::from_values(
+ "",
+ "xterm-ghostty",
+ Some("/dev/ttys019"),
+ None,
+ None,
+ true,
+ );
+ assert_eq!(ghostty.terminal, MacosTerminalKind::Ghostty);
+ assert_eq!(ghostty.bundle_id.as_deref(), Some("com.mitchellh.ghostty"));
+ }
+
+ #[test]
+ fn macos_origin_rejects_untrusted_route_values() {
+ let origin = MacosNotificationOrigin::from_values(
+ "Apple_Terminal",
+ "",
+ Some("/dev/ttys001\"\nrun script"),
+ Some("bad\nidentifier"),
+ None,
+ false,
+ );
+ assert_eq!(origin.tty, None);
+ assert_eq!(origin.session_id, None);
+
+ let (_, args) = macos_notification_activation_command(&origin).expect("Terminal route");
+ assert_eq!(
+ args,
+ vec!["-e", "tell application \"Terminal\" to activate"]
+ );
+ }
+
+ #[test]
+ fn macos_activation_targets_terminal_and_iterm_ttys() {
+ let terminal = MacosNotificationOrigin {
+ terminal: MacosTerminalKind::AppleTerminal,
+ bundle_id: Some("com.apple.Terminal".to_string()),
+ tty: Some("/dev/ttys003".to_string()),
+ session_id: Some("session-a".to_string()),
+ };
+ let (program, args) =
+ macos_notification_activation_command(&terminal).expect("Terminal command");
+ assert_eq!(program, "/usr/bin/osascript");
+ assert!(args[1].contains("if tty of t is \"/dev/ttys003\""));
+ assert!(args[1].contains("set selected tab of w to t"));
+
+ let iterm = MacosNotificationOrigin {
+ terminal: MacosTerminalKind::Iterm2,
+ bundle_id: Some("com.googlecode.iterm2".to_string()),
+ tty: Some("/dev/ttys004".to_string()),
+ session_id: Some("w0t0p0:guid".to_string()),
+ };
+ let (_, args) = macos_notification_activation_command(&iterm).expect("iTerm command");
+ assert!(args[1].contains("if tty of s is \"/dev/ttys004\""));
+ assert!(args[1].contains("select s"));
+ }
+
+ #[test]
+ fn macos_ghostty_activation_is_application_scoped() {
+ let origin = MacosNotificationOrigin {
+ terminal: MacosTerminalKind::Ghostty,
+ bundle_id: Some("evil.bundle".to_string()),
+ tty: Some("/dev/ttys005".to_string()),
+ session_id: None,
+ };
+ assert_eq!(
+ macos_notification_activation_command(&origin),
+ Some((
+ "/usr/bin/open".to_string(),
+ vec!["-b".to_string(), "com.mitchellh.ghostty".to_string()]
+ ))
+ );
+ }
+
+ #[test]
+ fn macos_envelope_roundtrip_preserves_origin_metadata() {
+ let envelope = MacosNotificationEnvelope {
+ schema_version: MACOS_NOTIFICATION_SCHEMA_VERSION,
+ notification_id: "jcode-turn-test".to_string(),
+ title: "jcode · done".to_string(),
+ subtitle: Some("2/2 todos".to_string()),
+ body: "Finished broker".to_string(),
+ sound: Some("Glass".to_string()),
+ origin: MacosNotificationOrigin {
+ terminal: MacosTerminalKind::Iterm2,
+ bundle_id: Some("com.googlecode.iterm2".to_string()),
+ tty: Some("/dev/ttys009".to_string()),
+ session_id: Some("w1t2p0:route".to_string()),
+ },
+ };
+ let encoded = serde_json::to_vec(&envelope).expect("encode envelope");
+ let decoded: MacosNotificationEnvelope =
+ serde_json::from_slice(&encoded).expect("decode envelope");
+ assert_eq!(decoded, envelope);
+ }
}
diff --git a/crates/jcode-app-core/src/perf.rs b/crates/jcode-app-core/src/perf.rs
index 1603ae5345..8153291372 100644
--- a/crates/jcode-app-core/src/perf.rs
+++ b/crates/jcode-app-core/src/perf.rs
@@ -129,6 +129,17 @@ pub fn profile() -> &'static SystemProfile {
PROFILE.get_or_init(detect)
}
+/// Pin the process-global system profile to the synthetic Full-tier profile.
+///
+/// Test harnesses call this so rendered output (perf badge, animation policy,
+/// idle status facts) does not depend on the host's load average or free
+/// memory at the moment the test process first touched `profile()`. First
+/// initialization wins: calling this after `profile()` has already run is a
+/// no-op, and production code paths never call it.
+pub fn pin_full_profile_for_tests() {
+ let _ = PROFILE.set(synthetic_profile(SyntheticSystemProfile::Native));
+}
+
pub fn synthetic_profile(kind: SyntheticSystemProfile) -> SystemProfile {
match kind {
SyntheticSystemProfile::Native => SystemProfile {
@@ -306,12 +317,27 @@ fn detect() -> SystemProfile {
&terminal,
);
- let tier = match crate::config::config().display.performance.as_str() {
- "full" => PerformanceTier::Full,
- "reduced" => PerformanceTier::Reduced,
- "minimal" => PerformanceTier::Minimal,
- _ => auto_tier,
- };
+ // Highest priority: explicit env override (used by tests/CI to keep the
+ // tier deterministic regardless of host load, and by users to force a
+ // tier for one invocation without editing config).
+ let env_tier = std::env::var("JCODE_PERF_TIER").ok().and_then(|raw| {
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "full" => Some(PerformanceTier::Full),
+ "reduced" => Some(PerformanceTier::Reduced),
+ "minimal" => Some(PerformanceTier::Minimal),
+ _ => None,
+ }
+ });
+
+ let tier =
+ env_tier.unwrap_or_else(
+ || match crate::config::config().display.performance.as_str() {
+ "full" => PerformanceTier::Full,
+ "reduced" => PerformanceTier::Reduced,
+ "minimal" => PerformanceTier::Minimal,
+ _ => auto_tier,
+ },
+ );
SystemProfile {
load_avg_1m,
diff --git a/crates/jcode-app-core/src/protocol_tests/comm_requests.rs b/crates/jcode-app-core/src/protocol_tests/comm_requests.rs
index a1176acd07..1a1a543904 100644
--- a/crates/jcode-app-core/src/protocol_tests/comm_requests.rs
+++ b/crates/jcode-app-core/src/protocol_tests/comm_requests.rs
@@ -384,11 +384,12 @@ fn test_comm_assign_next_roundtrip() -> Result<()> {
prefer_spawn: Some(true),
spawn_if_needed: Some(true),
message: Some("Take the next runnable task.".to_string()),
- model: Some("gpt-5.5".to_string()),
+ model: Some("openai-api:gpt-5.5".to_string()),
effort: Some("low".to_string()),
};
let json = serde_json::to_string(&req)?;
assert!(json.contains("\"type\":\"comm_assign_next\""));
+ assert!(json.contains("\"model\":\"openai-api:gpt-5.5\""));
let decoded = parse_request_json(&json)?;
assert_eq!(decoded.id(), 60);
let Request::CommAssignNext {
@@ -411,7 +412,7 @@ fn test_comm_assign_next_roundtrip() -> Result<()> {
assert_eq!(prefer_spawn, Some(true));
assert_eq!(spawn_if_needed, Some(true));
assert_eq!(message.as_deref(), Some("Take the next runnable task."));
- assert_eq!(model.as_deref(), Some("gpt-5.5"));
+ assert_eq!(model.as_deref(), Some("openai-api:gpt-5.5"));
assert_eq!(effort.as_deref(), Some("low"));
Ok(())
}
@@ -461,9 +462,9 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> {
assert!(json.contains("\"type\":\"comm_spawn\""));
assert!(json.contains("\"request_nonce\":\"planner-fresh-123\""));
assert!(json.contains("\"spawn_mode\":\"headless\""));
- assert!(json.contains("\"model\":\"openai-api:gpt-5.5\""));
assert!(json.contains("\"effort\":\"low\""));
assert!(json.contains("\"label\":\"review auth flow\""));
+ assert!(json.contains("\"model\":\"openai-api:gpt-5.5\""));
let decoded = parse_request_json(&json)?;
assert_eq!(decoded.id(), 59);
let Request::CommSpawn {
@@ -492,16 +493,74 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> {
}
#[test]
-fn test_comm_spawn_decodes_without_model_or_effort() -> Result<()> {
- // Older clients omit the model/effort fields entirely.
- let json = r#"{"type":"comm_spawn","id":60,"session_id":"sess_coord"}"#;
- let decoded = parse_request_json(json)?;
- let Request::CommSpawn { model, effort, label, .. } = decoded else {
- return Err(anyhow!("expected CommSpawn"));
- };
- assert_eq!(model, None);
- assert_eq!(effort, None);
- assert_eq!(label, None);
+fn test_comm_spawn_and_assign_next_decode_model_without_effort() -> Result<()> {
+ for request_type in ["comm_spawn", "comm_assign_next"] {
+ let json = serde_json::json!({
+ "type": request_type,
+ "id": 60,
+ "session_id": "sess_coord",
+ "model": "gpt-5.5"
+ });
+ let decoded = parse_request_json(&json.to_string())?;
+ match decoded {
+ Request::CommSpawn {
+ model,
+ effort,
+ label,
+ ..
+ } => {
+ assert_eq!(model.as_deref(), Some("gpt-5.5"));
+ assert_eq!(effort, None);
+ assert_eq!(label, None);
+ }
+ Request::CommAssignNext { model, effort, .. } => {
+ assert_eq!(model.as_deref(), Some("gpt-5.5"));
+ assert_eq!(effort, None);
+ }
+ _ => return Err(anyhow!("expected spawn or assign_next")),
+ }
+ }
+ Ok(())
+}
+
+#[test]
+fn test_comm_spawn_and_assign_next_roundtrip_omitted_or_null_model() -> Result<()> {
+ for request_type in ["comm_spawn", "comm_assign_next"] {
+ for explicit_null in [false, true] {
+ // Older clients omit the optional field. Explicit null must also work.
+ let mut json = serde_json::json!({
+ "type": request_type,
+ "id": 60,
+ "session_id": "sess_coord"
+ });
+ if explicit_null {
+ json["model"] = serde_json::Value::Null;
+ }
+ let decoded = parse_request_json(&json.to_string())?;
+ let encoded = serde_json::to_string(&decoded)?;
+ let roundtripped = parse_request_json(&encoded)?;
+ for request in [decoded, roundtripped] {
+ assert_eq!(request.id(), 60);
+ match request {
+ Request::CommSpawn {
+ model,
+ effort,
+ label,
+ ..
+ } => {
+ assert_eq!(model, None);
+ assert_eq!(effort, None);
+ assert_eq!(label, None);
+ }
+ Request::CommAssignNext { model, effort, .. } => {
+ assert_eq!(model, None);
+ assert_eq!(effort, None);
+ }
+ _ => return Err(anyhow!("expected spawn or assign_next")),
+ }
+ }
+ }
+ }
Ok(())
}
diff --git a/crates/jcode-app-core/src/protocol_tests/core_events.rs b/crates/jcode-app-core/src/protocol_tests/core_events.rs
index d0574e72dc..d6e02628ef 100644
--- a/crates/jcode-app-core/src/protocol_tests/core_events.rs
+++ b/crates/jcode-app-core/src/protocol_tests/core_events.rs
@@ -5,6 +5,7 @@ fn test_request_roundtrip() -> Result<()> {
content: "hello".to_string(),
images: vec![],
system_reminder: None,
+ no_reply: false,
};
let json = serde_json::to_string(&req)?;
let decoded = parse_request_json(&json)?;
@@ -71,6 +72,19 @@ fn test_event_roundtrip() -> Result<()> {
Ok(())
}
+#[test]
+fn test_context_message_added_event_roundtrip() -> Result<()> {
+ let event = ServerEvent::ContextMessageAdded { id: 42 };
+ let json = encode_event(&event);
+ assert!(json.contains("\"type\":\"context_message_added\""));
+ let decoded = parse_event_json(json.trim())?;
+ let ServerEvent::ContextMessageAdded { id } = decoded else {
+ return Err(anyhow!("wrong event type"));
+ };
+ assert_eq!(id, 42);
+ Ok(())
+}
+
#[test]
fn test_interrupted_event_decodes_from_json() -> Result<()> {
let json = r#"{"type":"interrupted"}"#;
@@ -190,6 +204,7 @@ fn test_history_event_roundtrip_preserves_side_panel_snapshot() -> Result<()> {
id: 101,
session_id: "ses_test_456".to_string(),
messages: vec![HistoryMessage {
+ response_stats: None,
role: "assistant".to_string(),
content: "hello".to_string(),
tool_calls: None,
@@ -281,6 +296,7 @@ fn test_compacted_history_event_roundtrip() -> Result<()> {
id: 77,
session_id: "ses_compact_123".to_string(),
messages: vec![HistoryMessage {
+ response_stats: None,
role: "assistant".to_string(),
content: "older response".to_string(),
tool_calls: None,
diff --git a/crates/jcode-app-core/src/protocol_tests/misc_events.rs b/crates/jcode-app-core/src/protocol_tests/misc_events.rs
index 281850709d..9a0356ef1a 100644
--- a/crates/jcode-app-core/src/protocol_tests/misc_events.rs
+++ b/crates/jcode-app-core/src/protocol_tests/misc_events.rs
@@ -205,6 +205,8 @@ fn test_subscribe_request_roundtrip_preserves_session_takeover_flags() -> Result
client_instance_id: Some("client-123".to_string()),
client_has_local_history: true,
allow_session_takeover: true,
+ crash_on_disconnect: true,
+ continue_on_disconnect: true,
terminal_env: vec![("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string())],
};
let json = serde_json::to_string(&req)?;
@@ -218,6 +220,8 @@ fn test_subscribe_request_roundtrip_preserves_session_takeover_flags() -> Result
client_instance_id,
client_has_local_history,
allow_session_takeover,
+ crash_on_disconnect,
+ continue_on_disconnect,
terminal_env,
} = decoded
else {
@@ -230,6 +234,8 @@ fn test_subscribe_request_roundtrip_preserves_session_takeover_flags() -> Result
assert_eq!(client_instance_id.as_deref(), Some("client-123"));
assert!(client_has_local_history);
assert!(allow_session_takeover);
+ assert!(crash_on_disconnect);
+ assert!(continue_on_disconnect);
assert_eq!(
terminal_env,
vec![("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string())]
@@ -249,6 +255,8 @@ fn test_subscribe_request_defaults_optional_flags() -> Result<()> {
client_instance_id,
client_has_local_history,
allow_session_takeover,
+ crash_on_disconnect,
+ continue_on_disconnect,
terminal_env,
} = decoded
else {
@@ -261,6 +269,8 @@ fn test_subscribe_request_defaults_optional_flags() -> Result<()> {
assert_eq!(client_instance_id, None);
assert!(!client_has_local_history);
assert!(!allow_session_takeover);
+ assert!(!crash_on_disconnect);
+ assert!(!continue_on_disconnect);
assert!(terminal_env.is_empty());
Ok(())
}
@@ -297,6 +307,7 @@ fn test_message_request_roundtrip_preserves_images_and_system_reminder() -> Resu
("image/jpeg".to_string(), "BBB".to_string()),
],
system_reminder: Some("be concise".to_string()),
+ no_reply: true,
};
let json = serde_json::to_string(&req)?;
let decoded = parse_request_json(&json)?;
@@ -305,6 +316,7 @@ fn test_message_request_roundtrip_preserves_images_and_system_reminder() -> Resu
content,
images,
system_reminder,
+ no_reply,
} = decoded
else {
return Err(anyhow!("expected Message"));
@@ -315,5 +327,37 @@ fn test_message_request_roundtrip_preserves_images_and_system_reminder() -> Resu
assert_eq!(images[0].0, "image/png");
assert_eq!(images[1].0, "image/jpeg");
assert_eq!(system_reminder.as_deref(), Some("be concise"));
+ assert!(no_reply);
+ Ok(())
+}
+
+#[test]
+fn test_native_ssh_pong_capability_is_backward_compatible() -> Result<()> {
+ let legacy: ServerEvent = serde_json::from_str(r#"{"type":"pong","id":7}"#)?;
+ assert!(matches!(
+ legacy,
+ ServerEvent::Pong {
+ id: 7,
+ native_ssh_protocol: None
+ }
+ ));
+ let modern = ServerEvent::Pong {
+ id: 7,
+ native_ssh_protocol: Some(1),
+ };
+ let json = serde_json::to_value(&modern)?;
+ assert_eq!(json["native_ssh_protocol"], 1);
+ assert!(matches!(
+ serde_json::from_value::(json)?,
+ ServerEvent::Pong {
+ id: 7,
+ native_ssh_protocol: Some(1)
+ }
+ ));
+ assert!(
+ serde_json::to_value(&legacy)?
+ .get("native_ssh_protocol")
+ .is_none()
+ );
Ok(())
}
diff --git a/crates/jcode-app-core/src/protocol_tests/randomized.rs b/crates/jcode-app-core/src/protocol_tests/randomized.rs
index e86531baa7..756a22830c 100644
--- a/crates/jcode-app-core/src/protocol_tests/randomized.rs
+++ b/crates/jcode-app-core/src/protocol_tests/randomized.rs
@@ -28,6 +28,7 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> {
content: content.clone(),
images: images.clone(),
system_reminder: system_reminder.clone(),
+ no_reply: rng.random_bool(0.5),
};
let decoded = parse_request_json(&serde_json::to_string(&req)?)?;
let Request::Message {
@@ -35,6 +36,7 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> {
content: decoded_content,
images: decoded_images,
system_reminder: decoded_system_reminder,
+ no_reply: decoded_no_reply,
} = decoded
else {
return Err(anyhow!("expected randomized Message"));
@@ -43,6 +45,10 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> {
assert_eq!(decoded_content, content);
assert_eq!(decoded_images, images);
assert_eq!(decoded_system_reminder, system_reminder);
+ assert_eq!(
+ decoded_no_reply,
+ matches!(req, Request::Message { no_reply: true, .. })
+ );
}
for id in 100..132u64 {
@@ -54,6 +60,8 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> {
let client_instance_id = rng.random_bool(0.5).then(|| format!("client-{}", id));
let client_has_local_history = rng.random_bool(0.5);
let allow_session_takeover = rng.random_bool(0.5);
+ let crash_on_disconnect = rng.random_bool(0.5);
+ let continue_on_disconnect = rng.random_bool(0.5);
let req = Request::Subscribe {
id,
working_dir: working_dir.clone(),
@@ -62,6 +70,8 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> {
client_instance_id: client_instance_id.clone(),
client_has_local_history,
allow_session_takeover,
+ crash_on_disconnect,
+ continue_on_disconnect,
terminal_env: Vec::new(),
};
let decoded = parse_request_json(&serde_json::to_string(&req)?)?;
@@ -73,6 +83,8 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> {
client_instance_id: decoded_client_instance_id,
client_has_local_history: decoded_client_has_local_history,
allow_session_takeover: decoded_allow_session_takeover,
+ crash_on_disconnect: decoded_crash_on_disconnect,
+ continue_on_disconnect: decoded_continue_on_disconnect,
terminal_env: _,
} = decoded
else {
@@ -85,6 +97,8 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> {
assert_eq!(decoded_client_instance_id, client_instance_id);
assert_eq!(decoded_client_has_local_history, client_has_local_history);
assert_eq!(decoded_allow_session_takeover, allow_session_takeover);
+ assert_eq!(decoded_crash_on_disconnect, crash_on_disconnect);
+ assert_eq!(decoded_continue_on_disconnect, continue_on_disconnect);
}
Ok(())
diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs
index 7abfc89d3a..f9301cddc0 100644
--- a/crates/jcode-app-core/src/server.rs
+++ b/crates/jcode-app-core/src/server.rs
@@ -1,3 +1,4 @@
+mod available_models_dedup;
mod await_members_state;
mod background_tasks;
mod client_actions;
@@ -51,9 +52,9 @@ mod util;
pub(super) use self::await_members_state::AwaitMembersRuntime;
use self::background_tasks::{
dispatch_background_task_completion, dispatch_background_task_progress,
- dispatch_swarm_await_completion, dispatch_swarm_batch_progress, dispatch_swarm_output_tail,
- dispatch_swarm_runtime_status, dispatch_swarm_todo_progress, dispatch_swarm_tool_activity,
- dispatch_ui_activity,
+ dispatch_background_task_stalled, dispatch_swarm_await_completion,
+ dispatch_swarm_batch_progress, dispatch_swarm_output_tail, dispatch_swarm_runtime_status,
+ dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, dispatch_ui_activity,
};
use self::debug::{ClientConnectionInfo, ClientDebugState};
use self::debug_jobs::DebugJob;
@@ -109,6 +110,35 @@ pub(super) type SessionAgents = Arc>>>>;
pub(super) type ChannelSubscriptions =
Arc>>>>;
+fn idle_monitor_should_start(client_count: usize, has_live_headless_worker: bool) -> bool {
+ client_count == 0 && !has_live_headless_worker
+}
+
+async fn has_live_headless_worker(sessions: &SessionAgents, swarm_state: &SwarmState) -> bool {
+ let live_sessions: HashSet = sessions.read().await.keys().cloned().collect();
+ swarm_state
+ .members
+ .read()
+ .await
+ .values()
+ .any(|member| member.is_headless && live_sessions.contains(&member.session_id))
+}
+
+/// Remove a live server session and its process-presence marker as one
+/// lifecycle operation. Server-owned sessions all share the long-running
+/// server PID, so leaving the marker behind makes presence UIs count the
+/// removed session forever.
+pub(super) async fn remove_session_entry(
+ sessions: &Arc>>,
+ session_id: &str,
+) -> Option {
+ let removed = sessions.write().await.remove(session_id);
+ if removed.is_some() {
+ crate::storage::unregister_active_pid(session_id);
+ }
+ removed
+}
+
const SERVER_NAME_ENV: &str = "JCODE_SERVER_NAME";
const SERVER_DISPLAY_NAME_ENV: &str = "JCODE_SERVER_DISPLAY_NAME";
const MAX_CONFIGURED_SERVER_NAME_LEN: usize = 64;
@@ -182,6 +212,105 @@ async fn prune_expired_terminal_swarm_members(
pruned
}
+/// Reap spawned swarm workers that finished their work and have sat idle past
+/// the reap window: ask any attached client window to close, shut down the
+/// server-side agent, and remove the member from swarm state.
+///
+/// This is the server-side backstop for coordinator `cleanup`: coordinators
+/// that get interrupted, replaced, or never call cleanup used to leave every
+/// spawned worker running forever (one ~80-150 MB client process each). Only
+/// agent-spawned members (`report_back_to_session_id` set) are eligible;
+/// user-created sessions are never touched. See
+/// [`swarm::idle_spawned_worker_reap_candidates`] for the exact policy.
+async fn reap_idle_spawned_workers(
+ sessions: &SessionAgents,
+ swarm_state: &SwarmState,
+ channel_subscriptions: &ChannelSubscriptions,
+ channel_subscriptions_by_session: &ChannelSubscriptions,
+ soft_interrupt_queues: &SessionInterruptQueues,
+) -> usize {
+ let Some(idle_after) = swarm::swarm_idle_worker_reap_after() else {
+ return 0;
+ };
+ let candidates = {
+ let members = swarm_state.members.read().await;
+ swarm::idle_spawned_worker_reap_candidates(&members, idle_after)
+ };
+ if candidates.is_empty() {
+ return 0;
+ }
+
+ let mut reaped = 0usize;
+ for session_id in candidates {
+ // Re-validate under the current map: status may have changed between
+ // candidate collection and removal (a resumed/reassigned worker).
+ let still_reapable = {
+ let members = swarm_state.members.read().await;
+ members.get(&session_id).is_some_and(|member| {
+ member.report_back_to_session_id.is_some()
+ && member.role != "coordinator"
+ && (member.status == "ready"
+ || swarm::member_status_is_terminal(&member.status))
+ && member.last_status_change.elapsed() >= idle_after
+ })
+ };
+ if !still_reapable {
+ continue;
+ }
+
+ // Ask any attached client (visible spawned window) to close itself.
+ let _ = fanout_session_event(
+ &swarm_state.members,
+ &session_id,
+ ServerEvent::SessionCloseRequested {
+ reason: format!(
+ "Idle spawned worker reaped after {}s of inactivity",
+ idle_after.as_secs()
+ ),
+ },
+ )
+ .await;
+
+ if let Some(agent_arc) = remove_session_entry(sessions, &session_id).await {
+ remove_session_interrupt_queue(soft_interrupt_queues, &session_id).await;
+ remove_background_tool_signal(&session_id);
+ if let Ok(mut agent) = agent_arc.try_lock() {
+ agent.mark_closed();
+ }
+ }
+
+ let removed_swarm_id = {
+ let mut members = swarm_state.members.write().await;
+ members
+ .remove(&session_id)
+ .and_then(|member| member.swarm_id)
+ };
+ if let Some(ref swarm_id) = removed_swarm_id {
+ remove_session_from_swarm(
+ &session_id,
+ swarm_id,
+ &swarm_state.members,
+ &swarm_state.swarms_by_id,
+ &swarm_state.coordinators,
+ &swarm_state.plans,
+ )
+ .await;
+ }
+ remove_session_channel_subscriptions(
+ &session_id,
+ channel_subscriptions,
+ channel_subscriptions_by_session,
+ )
+ .await;
+ crate::logging::info(&format!(
+ "Reaped idle spawned swarm worker {session_id} (idle > {}s)",
+ idle_after.as_secs()
+ ));
+ reaped += 1;
+ }
+ reaped
+}
+
pub(super) async fn persist_swarm_state_for(swarm_id: &str, swarm_state: &SwarmState) {
// Never call this while holding any SwarmState map guard. The operation
// lock deliberately spans the independent map reads and atomic file write.
@@ -497,7 +626,7 @@ pub use self::util::ServerIdentity;
pub(crate) use self::util::server_has_newer_binary;
use self::util::{
debug_control_allowed, embedding_idle_unload_secs, git_common_dir_for, reload_exec_target,
- startup_headless_recovery_test_delay, swarm_id_for_dir,
+ startup_headless_recovery_test_delay, swarm_id_for_dir, swarm_id_for_session,
};
mod file_activity;
@@ -521,8 +650,31 @@ mod file_activity_tests;
/// Idle timeout for the shared server when no clients are connected (5 minutes)
const IDLE_TIMEOUT_SECS: u64 = 300;
-/// How often to check whether the embedding model can be unloaded.
-const EMBEDDING_IDLE_CHECK_SECS: u64 = 30;
+/// How often to check whether the embedding model can be unloaded. Keep this
+/// comfortably below the default idle threshold so reclamation is prompt and
+/// predictable rather than delayed by another full sampling interval.
+const EMBEDDING_IDLE_CHECK_SECS: u64 = 10;
+
+#[cfg(test)]
+mod idle_monitor_tests {
+ use super::idle_monitor_should_start;
+
+ #[test]
+ fn shared_idle_monitor_preserves_live_headless_worker() {
+ assert!(!idle_monitor_should_start(0, true));
+ }
+
+ #[test]
+ fn temporary_idle_monitor_preserves_live_headless_worker() {
+ assert!(!idle_monitor_should_start(0, true));
+ }
+
+ #[test]
+ fn idle_monitor_starts_only_without_clients_or_headless_workers() {
+ assert!(idle_monitor_should_start(0, false));
+ assert!(!idle_monitor_should_start(1, false));
+ }
+}
/// How often the retained-heap watchdog samples allocator retention.
const HEAP_RETENTION_CHECK_SECS: u64 = 120;
@@ -1259,6 +1411,7 @@ impl Server {
let gc_channel_subscriptions = Arc::clone(&self.channel_subscriptions);
let gc_channel_subscriptions_by_session =
Arc::clone(&self.channel_subscriptions_by_session);
+ let gc_soft_interrupt_queues = Arc::clone(&self.soft_interrupt_queues);
tokio::spawn(async move {
let mut interval = tokio::time::interval(swarm::swarm_terminal_member_gc_interval());
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -1271,6 +1424,17 @@ impl Server {
&gc_channel_subscriptions_by_session,
)
.await;
+ // Backstop for coordinator cleanup: close finished spawned
+ // workers that have been idle past the reap window so they do
+ // not accumulate one leaked client process each.
+ reap_idle_spawned_workers(
+ &gc_sessions,
+ &gc_swarm_state,
+ &gc_channel_subscriptions,
+ &gc_channel_subscriptions_by_session,
+ &gc_soft_interrupt_queues,
+ )
+ .await;
}
});
@@ -1639,6 +1803,8 @@ impl Server {
if let Some(policy) = temporary_server_policy {
lifecycle::spawn_temporary_lifecycle_monitor(
Arc::clone(&self.client_count),
+ Arc::clone(&self.sessions),
+ self.swarm_state.clone(),
self.socket_path.clone(),
self.debug_socket_path.clone(),
self.identity.name.clone(),
@@ -1648,6 +1814,8 @@ impl Server {
crate::logging::info("Debug control enabled; idle timeout monitor disabled.");
} else {
let idle_client_count = Arc::clone(&self.client_count);
+ let idle_sessions = Arc::clone(&self.sessions);
+ let idle_swarm_state = self.swarm_state.clone();
let idle_server_name = self.identity.name.clone();
tokio::spawn(async move {
let mut idle_since: Option = None;
@@ -1657,8 +1825,10 @@ impl Server {
check_interval.tick().await;
let count = *idle_client_count.read().await;
+ let has_live_headless_worker =
+ has_live_headless_worker(&idle_sessions, &idle_swarm_state).await;
- if count == 0 {
+ if idle_monitor_should_start(count, has_live_headless_worker) {
// No clients connected
if idle_since.is_none() {
idle_since = Some(std::time::Instant::now());
@@ -2049,6 +2219,19 @@ impl Server {
Ok(BusEvent::BackgroundTaskProgress(task)) => {
dispatch_background_task_progress(&task, &swarm_members).await;
}
+ Ok(BusEvent::BackgroundTaskStalled(task)) => {
+ dispatch_background_task_stalled(
+ &task,
+ &sessions,
+ &soft_interrupt_queues,
+ &swarm_members,
+ &swarms_by_id,
+ &event_history,
+ &event_counter,
+ &swarm_event_tx,
+ )
+ .await;
+ }
Ok(BusEvent::SwarmAwaitCompleted(event)) => {
dispatch_swarm_await_completion(
&event,
diff --git a/crates/jcode-app-core/src/server/available_models_dedup.rs b/crates/jcode-app-core/src/server/available_models_dedup.rs
new file mode 100644
index 0000000000..31f04c7f2c
--- /dev/null
+++ b/crates/jcode-app-core/src/server/available_models_dedup.rs
@@ -0,0 +1,65 @@
+//! Dedup key for `AvailableModelsUpdated` catalog broadcasts.
+//!
+//! Split out of `client_lifecycle.rs` to keep that file off the code-size
+//! ratchet's growth list.
+
+use crate::protocol::ServerEvent;
+
+/// Dedup key for `AvailableModelsUpdated` that ignores cosmetic relative-age
+/// text in route details.
+///
+/// Route details embed human-readable cache ages ("12m ago", "3h ago"). Those
+/// strings tick forward on their own, so a byte-comparison of encoded events
+/// reports a "change" every time an age bucket rolls over even though no model
+/// or route actually changed. That made otherwise-identical catalogs fan out to
+/// every connected client, and each client then invalidated its picker cache,
+/// rewrote its on-disk catalog cache, and repainted a full frame, which starved
+/// the input line.
+///
+/// Normalizing those substrings out means a catalog whose only difference is
+/// elapsed time is treated as unchanged and never broadcast. Real changes
+/// (models, providers, api methods, availability, non-age detail text) still
+/// differ and still propagate.
+pub(super) fn available_models_dedup_key(event: &ServerEvent) -> String {
+ let encoded = crate::protocol::encode_event(event);
+ strip_relative_age_text(&encoded)
+}
+
+/// Replace ` ago` runs (e.g. `12m ago`, `3h ago`, `5d ago`) with a
+/// fixed placeholder so relative-age drift does not register as a change.
+pub(super) fn strip_relative_age_text(text: &str) -> String {
+ const AGE_SUFFIX: &str = " ago";
+ let mut out = String::with_capacity(text.len());
+ let bytes = text.as_bytes();
+ let mut i = 0;
+ while i < text.len() {
+ // Look for the start of a " ago" run at this position.
+ let digits_end = bytes[i..]
+ .iter()
+ .position(|b| !b.is_ascii_digit())
+ .map(|offset| i + offset)
+ .unwrap_or(text.len());
+ let has_digits = digits_end > i;
+ let unit_is_age = matches!(bytes.get(digits_end), Some(b's' | b'm' | b'h' | b'd'));
+ // Only slice past the unit byte once we know a unit byte exists, so a
+ // trailing digit run at end-of-string cannot index out of range.
+ if has_digits && unit_is_age && text[digits_end + 1..].starts_with(AGE_SUFFIX) {
+ out.push_str("");
+ i = digits_end + 1 + AGE_SUFFIX.len();
+ continue;
+ }
+ // Not an age run: copy one char and continue from the next boundary.
+ // `i` is always a char boundary here, so `chars().next()` yields a char
+ // for any non-empty remainder; fall through to the end otherwise.
+ let Some(ch) = text[i..].chars().next() else {
+ break;
+ };
+ out.push(ch);
+ i += ch.len_utf8();
+ }
+ out
+}
+
+#[cfg(test)]
+#[path = "client_lifecycle_catalog_dedup_tests.rs"]
+mod tests;
diff --git a/crates/jcode-app-core/src/server/background_tasks.rs b/crates/jcode-app-core/src/server/background_tasks.rs
index 6b68c8a86a..a294192e71 100644
--- a/crates/jcode-app-core/src/server/background_tasks.rs
+++ b/crates/jcode-app-core/src/server/background_tasks.rs
@@ -14,6 +14,28 @@ use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use tokio::sync::{RwLock, broadcast};
+async fn emit_external_wake(
+ session_id: &str,
+ reason: &str,
+ notification: &str,
+ swarm_members: &Arc>>,
+) -> bool {
+ if crate::config::config().server.wake_mode != crate::config::WakeMode::External {
+ return false;
+ }
+ let _ = fanout_session_event(
+ swarm_members,
+ session_id,
+ ServerEvent::WakeRequested {
+ session_id: session_id.to_string(),
+ reason: reason.to_string(),
+ notification: notification.to_string(),
+ },
+ )
+ .await;
+ true
+}
+
#[expect(
clippy::too_many_arguments,
reason = "background task completion needs session, interrupt, and swarm status state"
@@ -55,6 +77,13 @@ pub(super) async fn dispatch_background_task_completion(
}
if task.wake
+ && !emit_external_wake(
+ &task.session_id,
+ "background_task_completed",
+ ¬ification,
+ swarm_members,
+ )
+ .await
&& !run_live_turn_if_idle(
&task.session_id,
¬ification,
@@ -89,6 +118,93 @@ pub(super) async fn dispatch_background_task_completion(
}
}
+/// Deliver a stall-watchdog wake for a background task that has gone quiet.
+///
+/// Mirrors completion delivery: optionally notify attached clients, then wake
+/// an idle agent or queue a soft interrupt for a busy one. The task is still
+/// running; the message tells the agent to inspect and decide.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "background task stall delivery needs session, interrupt, and swarm status state"
+)]
+pub(super) async fn dispatch_background_task_stalled(
+ task: &crate::bus::BackgroundTaskStalled,
+ sessions: &SessionAgents,
+ soft_interrupt_queues: &SessionInterruptQueues,
+ swarm_members: &Arc>>,
+ swarms_by_id: &Arc>>>,
+ event_history: &Arc>>,
+ event_counter: &Arc,
+ swarm_event_tx: &broadcast::Sender,
+) {
+ let notification = crate::message::format_background_task_stalled_markdown(task);
+
+ if task.notify
+ && fanout_session_event(
+ swarm_members,
+ &task.session_id,
+ ServerEvent::Notification {
+ from_session: "background_task".to_string(),
+ from_name: Some("background task".to_string()),
+ notification_type: NotificationType::Message {
+ scope: Some("background_task".to_string()),
+ channel: None,
+ tldr: None,
+ },
+ message: notification.clone(),
+ },
+ )
+ .await
+ == 0
+ {
+ crate::logging::warn(&format!(
+ "Failed to notify attached clients for background task stall on session {}",
+ task.session_id
+ ));
+ }
+
+ if task.wake
+ && !emit_external_wake(
+ &task.session_id,
+ "background_task_stalled",
+ ¬ification,
+ swarm_members,
+ )
+ .await
+ && !run_live_turn_if_idle(
+ &task.session_id,
+ ¬ification,
+ Some(
+ "A background task for this session has produced no output or progress for its stall window. Inspect it and decide whether to keep waiting, fix it, or cancel it."
+ .to_string(),
+ ),
+ sessions,
+ LiveTurnSwarmContext::new(
+ swarm_members,
+ swarms_by_id,
+ event_history,
+ event_counter,
+ swarm_event_tx,
+ ),
+ )
+ .await
+ && !queue_soft_interrupt_for_session(
+ &task.session_id,
+ notification.clone(),
+ false,
+ SoftInterruptSource::BackgroundTask,
+ soft_interrupt_queues,
+ sessions,
+ )
+ .await
+ {
+ crate::logging::warn(&format!(
+ "Failed to deliver background task stall to session {}",
+ task.session_id
+ ));
+ }
+}
+
/// Deliver the result of a backgrounded `swarm await_members` watcher to the
/// requesting session. Mirrors background-task completion delivery: optionally
/// notify attached clients, then wake an idle agent or queue a soft interrupt
@@ -135,6 +251,17 @@ pub(super) async fn dispatch_swarm_await_completion(
return;
}
+ if emit_external_wake(
+ &event.session_id,
+ "swarm_await_completed",
+ &event.notification,
+ swarm_members,
+ )
+ .await
+ {
+ return;
+ }
+
if !run_live_turn_if_idle(
&event.session_id,
&event.notification,
diff --git a/crates/jcode-app-core/src/server/client_actions.rs b/crates/jcode-app-core/src/server/client_actions.rs
index 67dba99278..f2ad66a3b4 100644
--- a/crates/jcode-app-core/src/server/client_actions.rs
+++ b/crates/jcode-app-core/src/server/client_actions.rs
@@ -5,7 +5,7 @@ use super::{
ClientConnectionInfo, SessionInterruptQueues, SwarmEvent, SwarmMember, SwarmState,
VersionedPlan, broadcast_swarm_status, fanout_session_event, persist_swarm_state_for,
queue_soft_interrupt_for_session, remove_session_channel_subscriptions,
- remove_session_from_swarm, swarm_id_for_dir, truncate_detail, update_member_status,
+ remove_session_from_swarm, swarm_id_for_session, truncate_detail, update_member_status,
};
use crate::agent::Agent;
use crate::protocol::{FeatureToggle, NotificationType, ServerEvent};
@@ -105,10 +105,9 @@ pub(super) async fn handle_notify_session(
};
let ran_immediately = if target_has_client {
- super::live_turn::run_live_turn_if_idle(
+ super::live_turn::run_live_system_turn_if_idle(
&session_id,
&message,
- None,
ctx.sessions,
super::live_turn::LiveTurnSwarmContext::new(
ctx.swarm_members,
@@ -500,7 +499,8 @@ pub(super) async fn handle_set_feature(
}
if enabled {
- let new_swarm_id = swarm_id_for_dir(working_dir);
+ let _ = working_dir;
+ let new_swarm_id = swarm_id_for_session(client_session_id);
if let Some(ref id) = new_swarm_id {
{
let mut swarms = swarms_by_id.write().await;
@@ -655,8 +655,23 @@ pub(super) async fn handle_trigger_memory_extraction(
let _ = client_event_tx.send(ServerEvent::Done { id });
}
-fn clone_split_session(parent_session_id: &str) -> anyhow::Result<(String, String)> {
- let parent = Session::load(parent_session_id)?;
+fn clone_split_session(
+ parent_session_id: &str,
+ live_parent: Option<&Session>,
+) -> anyhow::Result<(String, String)> {
+ // Keep the persisted snapshot authoritative, including while the parent is
+ // busy. A brand-new Agent may not have saved anything yet, however. Only a
+ // missing snapshot permits an in-memory fallback, never corrupt/unreadable
+ // history or a session belonging to a different client.
+ let parent = Session::load(parent_session_id).or_else(|error| {
+ let missing = error
+ .downcast_ref::()
+ .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound);
+ match live_parent.filter(|parent| missing && parent.id == parent_session_id) {
+ Some(parent) => Ok(parent.clone()),
+ None => Err(error),
+ }
+ })?;
let mut child = Session::create(Some(parent_session_id.to_string()), None);
child.replace_messages(parent.messages.clone());
@@ -715,6 +730,7 @@ fn create_transfer_child_session(
pub(super) async fn handle_split(
id: u64,
client_session_id: &str,
+ agent: &Arc>,
client_event_tx: &mpsc::UnboundedSender,
) {
let started = Instant::now();
@@ -726,7 +742,16 @@ pub(super) async fn handle_split(
("session_id", client_session_id.to_string()),
],
);
- let (new_session_id, new_session_name) = match clone_split_session(client_session_id) {
+ // Splitting must remain available during a streaming turn. Never await the
+ // Agent lock: busy sessions can still fork their last persisted snapshot.
+ let result = {
+ let idle_agent = agent.try_lock().ok();
+ clone_split_session(
+ client_session_id,
+ idle_agent.as_ref().map(|agent| agent.session_for_split()),
+ )
+ };
+ let (new_session_id, new_session_name) = match result {
Ok(result) => result,
Err(e) => {
crate::logging::event_warn(
@@ -954,7 +979,8 @@ pub(super) async fn handle_resume_all_sessions(
};
// Only act on idle sessions; a busy session is already making progress.
- let Ok(agent_guard) = agent.try_lock() else {
+ // The owned guard doubles as the turn reservation (#1152).
+ let Ok(agent_guard) = Arc::clone(&agent).try_lock_owned() else {
skipped += 1;
continue;
};
@@ -973,7 +999,6 @@ pub(super) async fn handle_resume_all_sessions(
.session_short_name()
.map(str::to_string)
.unwrap_or_else(|| session_id[..8.min(session_id.len())].to_string());
- drop(agent_guard);
// Best-effort: record that the durable recovery intent was delivered.
if let Err(error) = super::reload_recovery::mark_delivered_if_matching_continuation(
@@ -989,9 +1014,10 @@ pub(super) async fn handle_resume_all_sessions(
super::live_turn::spawn_tracked_live_turn(
&session_id,
- Arc::clone(&agent),
+ agent_guard,
String::new(),
Some(reminder),
+ None,
Some("resuming interrupted session".to_string()),
super::live_turn::LiveTurnSwarmContext::new(
swarm_members,
diff --git a/crates/jcode-app-core/src/server/client_actions_tests.rs b/crates/jcode-app-core/src/server/client_actions_tests.rs
index 6100880d98..a029ffe49e 100644
--- a/crates/jcode-app-core/src/server/client_actions_tests.rs
+++ b/crates/jcode-app-core/src/server/client_actions_tests.rs
@@ -2,7 +2,7 @@
use super::{
NotifySessionContext, clone_split_session, handle_notify_session, handle_rename_session,
- handle_resume_all_sessions, handle_set_feature,
+ handle_resume_all_sessions, handle_set_feature, handle_split,
};
use crate::agent::Agent;
use crate::message::{ContentBlock, Message, Role, StreamEvent, ToolDefinition};
@@ -134,7 +134,17 @@ fn clone_split_session_uses_persisted_session_state() {
});
parent.save().expect("save parent");
- let (child_id, _child_name) = clone_split_session(&parent.id).expect("clone split");
+ let mut unsaved_parent = parent.clone();
+ unsaved_parent.model = Some("unsaved-model".into());
+ unsaved_parent.add_message(
+ Role::Assistant,
+ vec![ContentBlock::Text {
+ text: "unfinished turn".into(),
+ cache_control: None,
+ }],
+ );
+ let (child_id, _child_name) =
+ clone_split_session(&parent.id, Some(&unsaved_parent)).expect("clone split");
let child = crate::session::Session::load(&child_id).expect("load child");
assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str()));
@@ -171,6 +181,196 @@ fn clone_split_session_uses_persisted_session_state() {
}
}
+struct SplitTestHome {
+ _directory: tempfile::TempDir,
+ previous_home: Option,
+}
+
+impl SplitTestHome {
+ fn new() -> Self {
+ let directory = tempfile::tempdir().expect("split test home");
+ let previous_home = std::env::var_os("JCODE_HOME");
+ crate::env::set_var("JCODE_HOME", directory.path());
+ Self {
+ _directory: directory,
+ previous_home,
+ }
+ }
+}
+
+impl Drop for SplitTestHome {
+ fn drop(&mut self) {
+ if let Some(home) = &self.previous_home {
+ crate::env::set_var("JCODE_HOME", home);
+ } else {
+ crate::env::remove_var("JCODE_HOME");
+ }
+ }
+}
+
+async fn new_split_test_agent() -> Arc> {
+ let provider: Arc = Arc::new(MockProvider);
+ let registry = Registry::new(provider.clone()).await;
+ Arc::new(Mutex::new(Agent::new_with_initial_working_dir(
+ provider,
+ registry,
+ Some("/project/empty-split"),
+ )))
+}
+
+fn split_response(
+ rx: &mut mpsc::UnboundedReceiver,
+ request_id: u64,
+) -> crate::session::Session {
+ let event = rx.try_recv().expect("split must respond");
+ let ServerEvent::SplitResponse {
+ id,
+ new_session_id,
+ new_session_name,
+ } = event
+ else {
+ panic!("expected SplitResponse, got {event:?}");
+ };
+ assert_eq!(id, request_id);
+ assert!(!new_session_name.is_empty());
+ assert!(rx.try_recv().is_err(), "exactly one split response");
+ crate::session::Session::load(&new_session_id).expect("fork must be persisted for attachment")
+}
+
+#[tokio::test]
+async fn split_empty_live_session_without_persisted_parent() {
+ let _guard = crate::storage::lock_test_env();
+ let _home = SplitTestHome::new();
+ let agent = new_split_test_agent().await;
+ let parent = agent.lock().await.session_for_split().clone();
+ assert_eq!(parent.visible_conversation_message_count(), 0);
+ assert!(
+ !crate::session::session_exists(&parent.id),
+ "regression requires an unsaved parent"
+ );
+ let (tx, mut rx) = mpsc::unbounded_channel();
+
+ handle_split(17, &parent.id, &agent, &tx).await;
+ let child = split_response(&mut rx, 17);
+ assert_ne!(child.id, parent.id);
+ assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str()));
+ assert_eq!(child.working_dir, parent.working_dir);
+ assert_eq!(child.model, parent.model);
+ assert_eq!(child.status, crate::session::SessionStatus::Closed);
+ assert_eq!(child.messages.len(), parent.messages.len() + 1);
+ let notice = child.messages.last().unwrap();
+ assert_eq!(
+ notice.display_role,
+ Some(crate::session::StoredDisplayRole::System)
+ );
+ assert!(notice.content_preview().contains(&parent.id));
+ assert_eq!(agent.lock().await.session_id(), parent.id);
+ assert!(
+ !crate::session::session_exists(&parent.id),
+ "fork must not mutate/persist its parent"
+ );
+}
+
+#[tokio::test]
+async fn split_busy_session_uses_persisted_state_without_waiting_for_agent() {
+ let _guard = crate::storage::lock_test_env();
+ let _home = SplitTestHome::new();
+ let agent = new_split_test_agent().await;
+ let mut busy = agent.lock().await;
+ let mut parent = busy.session_for_split().clone();
+ parent.add_message(
+ Role::User,
+ vec![ContentBlock::Text {
+ text: "persisted request".into(),
+ cache_control: None,
+ }],
+ );
+ parent.save().expect("save pre-turn snapshot");
+ busy.add_message(
+ Role::Assistant,
+ vec![ContentBlock::Text {
+ text: "unsaved streaming output".into(),
+ cache_control: None,
+ }],
+ );
+ let (tx, mut rx) = mpsc::unbounded_channel();
+
+ timeout(
+ Duration::from_millis(100),
+ handle_split(18, &parent.id, &agent, &tx),
+ )
+ .await
+ .expect("split must not wait on the held streaming Agent lock");
+ let child = split_response(&mut rx, 18);
+ assert_eq!(child.messages.len(), parent.messages.len() + 1);
+ assert_eq!(
+ child.messages[0].content_preview(),
+ parent.messages[0].content_preview()
+ );
+ assert!(
+ !child
+ .messages
+ .iter()
+ .any(|m| m.content_preview().contains("unsaved streaming output"))
+ );
+ assert!(
+ child
+ .messages
+ .last()
+ .unwrap()
+ .content_preview()
+ .contains("forked")
+ );
+ assert!(
+ agent.try_lock().is_err(),
+ "parent lock is still owned by the busy turn"
+ );
+ drop(busy);
+}
+
+#[tokio::test]
+async fn split_busy_unsaved_session_returns_error_without_waiting() {
+ let _guard = crate::storage::lock_test_env();
+ let _home = SplitTestHome::new();
+ let agent = new_split_test_agent().await;
+ let busy = agent.lock().await;
+ let parent_id = busy.session_id().to_owned();
+ assert!(!crate::session::session_exists(&parent_id));
+ let (tx, mut rx) = mpsc::unbounded_channel();
+ timeout(
+ Duration::from_millis(100),
+ handle_split(19, &parent_id, &agent, &tx),
+ )
+ .await
+ .expect("missing snapshot must not block a busy session");
+ assert!(matches!(
+ rx.try_recv(),
+ Ok(ServerEvent::Error { id: 19, .. })
+ ));
+ assert!(rx.try_recv().is_err());
+ drop(busy);
+}
+
+#[test]
+fn split_missing_parent_never_uses_another_live_session() {
+ let _guard = crate::storage::lock_test_env();
+ let _home = SplitTestHome::new();
+ let other = crate::session::Session::create(None, None);
+ assert!(clone_split_session("session_missing_parent", Some(&other)).is_err());
+}
+
+#[test]
+fn split_corrupt_persisted_parent_is_not_hidden_by_live_fallback() {
+ let _guard = crate::storage::lock_test_env();
+ let _home = SplitTestHome::new();
+ let mut parent = crate::session::Session::create(None, Some("persisted parent".into()));
+ parent.save().expect("create snapshot");
+ let path = crate::session::session_path(&parent.id).unwrap();
+ std::fs::write(&path, b"invalid session JSON").unwrap();
+ assert!(clone_split_session(&parent.id, Some(&parent)).is_err());
+ assert_eq!(std::fs::read(&path).unwrap(), b"invalid session JSON");
+}
+
#[tokio::test]
async fn enabling_swarm_does_not_auto_elect_coordinator() {
let provider: Arc = Arc::new(MockProvider);
@@ -461,6 +661,7 @@ async fn notify_session_runs_scheduled_task_immediately_for_idle_live_session()
let guard = agent.lock().await;
assert!(guard.messages().iter().any(|message| {
message.role == Role::User
+ && message.display_role == Some(crate::session::StoredDisplayRole::System)
&& message
.content_preview()
.contains("[Scheduled task] Task: Follow up")
diff --git a/crates/jcode-app-core/src/server/client_api.rs b/crates/jcode-app-core/src/server/client_api.rs
index fe2a55028c..d1098fedf6 100644
--- a/crates/jcode-app-core/src/server/client_api.rs
+++ b/crates/jcode-app-core/src/server/client_api.rs
@@ -51,6 +51,8 @@ impl Client {
content: content.to_string(),
images: vec![],
system_reminder: None,
+ active_skill: None,
+ no_reply: false,
};
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
@@ -86,6 +88,8 @@ impl Client {
client_instance_id: None,
client_has_local_history,
allow_session_takeover,
+ crash_on_disconnect: false,
+ continue_on_disconnect: false,
terminal_env: crate::terminal_launch::snapshot_client_terminal_env(),
};
let json = serde_json::to_string(&request)? + "\n";
@@ -121,7 +125,7 @@ impl Client {
let event: ServerEvent = serde_json::from_str(&line)?;
match event {
- ServerEvent::Pong { id: pong_id } => return Ok(pong_id == id),
+ ServerEvent::Pong { id: pong_id, .. } => return Ok(pong_id == id),
ServerEvent::Ack { id: ack_id } if ack_id == id => continue,
ServerEvent::Error { id: error_id, .. } if error_id == id => return Ok(false),
_ => return Ok(false),
diff --git a/crates/jcode-app-core/src/server/client_comm_context.rs b/crates/jcode-app-core/src/server/client_comm_context.rs
index 28719c86d8..8a31a62678 100644
--- a/crates/jcode-app-core/src/server/client_comm_context.rs
+++ b/crates/jcode-app-core/src/server/client_comm_context.rs
@@ -312,6 +312,7 @@ pub(super) async fn handle_comm_list(
activity: extras.activity,
provider_name: extras.provider_name,
provider_model: extras.provider_model,
+ provider_effort: extras.provider_effort,
turn_count: extras.turn_count,
recent_total_tokens: extras.recent_total_tokens,
recent_output_tokens: extras.recent_output_tokens,
diff --git a/crates/jcode-app-core/src/server/client_comm_message.rs b/crates/jcode-app-core/src/server/client_comm_message.rs
index 2ea0f35dca..34332d5795 100644
--- a/crates/jcode-app-core/src/server/client_comm_message.rs
+++ b/crates/jcode-app-core/src/server/client_comm_message.rs
@@ -336,6 +336,22 @@ pub(super) async fn handle_comm_message(
.await;
}
CommDeliveryMode::Wake => {
+ if crate::config::config().server.wake_mode
+ == crate::config::WakeMode::External
+ {
+ let _ = fanout_session_event(
+ swarm_members,
+ session_id,
+ ServerEvent::WakeRequested {
+ session_id: session_id.to_string(),
+ reason: "communication_delivery".to_string(),
+ notification: notification_msg.clone(),
+ },
+ )
+ .await;
+ delivered_targets += 1;
+ continue;
+ }
let woke_immediately = run_live_turn_if_idle(
session_id,
¬ification_msg,
diff --git a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs
index ea0b0eee44..3448328331 100644
--- a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs
+++ b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs
@@ -10,12 +10,28 @@ use jcode_agent_runtime::InterruptSignal;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
-use tokio::sync::{Mutex, RwLock, broadcast};
+use tokio::sync::{Mutex, RwLock, broadcast, mpsc};
type SessionAgents = Arc>>>>;
type ChannelSubscriptions = Arc>>>>;
const RELOAD_DISCONNECT_MARKER_MAX_AGE: Duration = Duration::from_secs(30);
+pub(super) const IDLE_RECONNECT_GRACE: Duration = Duration::from_secs(30);
+
+// The last registered event sender remains on the member after it detaches.
+// It is therefore also an ownership witness: an old grace timer must not
+// remove a successor's session even if that successor has disconnected again.
+async fn attachment_was_replaced(
+ members: &Arc>>,
+ session_id: &str,
+ original: &mpsc::UnboundedSender,
+) -> bool {
+ members
+ .read()
+ .await
+ .get(session_id)
+ .is_none_or(|member| !member.event_tx.same_channel(original))
+}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DisconnectDisposition {
@@ -25,6 +41,8 @@ enum DisconnectDisposition {
}
fn disconnect_disposition(disconnected_while_processing: bool) -> DisconnectDisposition {
+ // Losing the UI is only a session crash when it interrupts unfinished work.
+ // In particular, force-quitting Desktop after Done is an ordinary close.
if !disconnected_while_processing {
return DisconnectDisposition::Closed;
}
@@ -36,15 +54,29 @@ fn disconnect_disposition(disconnected_while_processing: bool) -> DisconnectDisp
}
}
-async fn session_has_live_successor(
- client_connections: &Arc>>,
- session_id: &str,
+fn disconnected_while_processing(
+ client_is_processing: bool,
+ processing_task: Option<&tokio::task::JoinHandle<()>>,
) -> bool {
- client_connections
- .read()
- .await
- .values()
- .any(|info| info.session_id == session_id)
+ // Socket EOF is prioritized over processing_done_rx. A finished task is
+ // authoritative even if the client's cached processing flag is still set.
+ processing_task
+ .map(|handle| !handle.is_finished())
+ .unwrap_or(client_is_processing)
+}
+
+/// Release transport-owned state without changing the live session or turn.
+pub(super) async fn detach_client_attachment(
+ session_id: &str,
+ connection_id: &str,
+ debug_id: &str,
+ client_connections: &Arc>>,
+ client_debug_state: &Arc>,
+ swarm_members: &Arc>>,
+) {
+ client_debug_state.write().await.unregister(debug_id);
+ client_connections.write().await.remove(connection_id);
+ unregister_session_event_sender(swarm_members, session_id, connection_id).await;
}
#[expect(
@@ -73,31 +105,92 @@ pub(super) async fn cleanup_client_connection(
event_history: &Arc>>,
event_counter: &Arc,
swarm_event_tx: &broadcast::Sender,
+ client_event_tx: &mpsc::UnboundedSender,
+ idle_reconnect_grace: Duration,
) -> Result<()> {
- let disconnected_while_processing = client_is_processing
- || processing_task
- .as_ref()
- .map(|handle| !handle.is_finished())
- .unwrap_or(false);
- let disposition = disconnect_disposition(disconnected_while_processing);
-
+ let disposition = disconnect_disposition(disconnected_while_processing(
+ client_is_processing,
+ processing_task.as_ref(),
+ ));
+ let allow_reconnect = if disposition == DisconnectDisposition::Closed
+ && !crate::session::session_exists(client_session_id)
{
- let mut debug_state = client_debug_state.write().await;
- debug_state.unregister(client_debug_id);
+ let agent = sessions.read().await.get(client_session_id).cloned();
+ agent.is_some_and(|agent| {
+ agent
+ .try_lock()
+ .is_ok_and(|agent| agent.visible_conversation_message_count() == 0)
+ })
+ } else {
+ false
+ };
+
+ // A live processing task owns the agent mutex. Abort it before trying to
+ // persist the disconnect disposition; otherwise cleanup waits two seconds,
+ // times out, and leaves the durable session `Active` precisely when an
+ // interrupted desktop turn must become `Crashed`.
+ if let Some(handle) = processing_task.take() {
+ handle.abort();
}
- {
- let mut connections = client_connections.write().await;
- connections.remove(client_connection_id);
+
+ detach_client_attachment(
+ client_session_id,
+ client_connection_id,
+ client_debug_id,
+ client_connections,
+ client_debug_state,
+ swarm_members,
+ )
+ .await;
+
+ if allow_reconnect {
+ // Empty roots intentionally have no snapshot. A replacement UI/SDK
+ // connection needs a bounded opportunity to reclaim the live Agent,
+ // without creating history entries for every briefly opened panel.
+ // No registry or agent lock is held across the wait. Processing/crash
+ // cleanup never enters this path.
+ event_handle.abort();
+ crate::logging::info(&format!(
+ "Retaining idle unsaved session {} for reconnect grace",
+ client_session_id
+ ));
+ let deadline = tokio::time::Instant::now() + idle_reconnect_grace;
+ loop {
+ if attachment_was_replaced(swarm_members, client_session_id, client_event_tx).await
+ || client_connections
+ .read()
+ .await
+ .values()
+ .any(|info| info.session_id == client_session_id)
+ {
+ return Ok(());
+ }
+ if tokio::time::Instant::now() >= deadline {
+ break;
+ }
+ tokio::time::sleep_until(std::cmp::min(
+ deadline,
+ tokio::time::Instant::now() + Duration::from_millis(25),
+ ))
+ .await;
+ }
}
- unregister_session_event_sender(swarm_members, client_session_id, client_connection_id).await;
// Release stale live ownership before slower cleanup so a reconnecting TUI can
// reclaim the same session without tripping duplicate-attach guards.
tokio::task::yield_now().await;
- let successor_connected =
- session_has_live_successor(client_connections, client_session_id).await;
- if successor_connected {
+ // Resume claims use this same lock before accessing the sessions map.
+ // Keep it through destructive cleanup so a successor cannot be claimed
+ // between the check and session/status/control-handle removal.
+ let connections = client_connections.write().await;
+ let successor_connected = connections
+ .values()
+ .any(|info| info.session_id == client_session_id);
+ if successor_connected
+ || (allow_reconnect
+ && attachment_was_replaced(swarm_members, client_session_id, client_event_tx).await)
+ {
crate::logging::info(&format!(
"Skipping destructive disconnect cleanup for {} because another client is still attached",
client_session_id
@@ -107,9 +200,7 @@ pub(super) async fn cleanup_client_connection(
}
{
- let mut sessions_guard = sessions.write().await;
- if let Some(agent_arc) = sessions_guard.remove(client_session_id) {
- drop(sessions_guard);
+ if let Some(agent_arc) = super::remove_session_entry(sessions, client_session_id).await {
let lock_result =
tokio::time::timeout(std::time::Duration::from_secs(2), agent_arc.lock()).await;
@@ -253,23 +344,45 @@ pub(super) async fn cleanup_client_connection(
remove_background_tool_signal(client_session_id);
remove_session_interrupt_queue(soft_interrupt_queues, client_session_id).await;
- if let Some(handle) = processing_task.take() {
- handle.abort();
- }
-
+ drop(connections);
event_handle.abort();
Ok(())
}
+#[cfg(test)]
+#[path = "client_disconnect_grace_tests.rs"]
+mod grace_tests;
+
#[cfg(test)]
mod tests {
- use super::{DisconnectDisposition, disconnect_disposition};
+ use super::{DisconnectDisposition, disconnect_disposition, disconnected_while_processing};
#[test]
fn idle_disconnect_is_closed() {
assert_eq!(disconnect_disposition(false), DisconnectDisposition::Closed);
}
+ #[tokio::test]
+ async fn completed_task_overrides_stale_processing_flag() {
+ let task = tokio::spawn(async {});
+ while !task.is_finished() {
+ tokio::task::yield_now().await;
+ }
+ assert_eq!(
+ disconnect_disposition(disconnected_while_processing(true, Some(&task))),
+ DisconnectDisposition::Closed
+ );
+ }
+
+ #[tokio::test]
+ async fn unfinished_task_is_processing_even_without_cached_flag() {
+ let task = tokio::spawn(std::future::pending::<()>());
+ assert!(disconnected_while_processing(false, Some(&task)));
+ task.abort();
+ assert!(disconnected_while_processing(true, None));
+ assert!(!disconnected_while_processing(false, None));
+ }
+
#[test]
fn running_disconnect_without_reload_is_crash() {
let _guard = crate::storage::lock_test_env();
@@ -293,6 +406,7 @@ mod tests {
disconnect_disposition(true),
DisconnectDisposition::Reloading
);
+ assert_eq!(disconnect_disposition(false), DisconnectDisposition::Closed);
crate::server::clear_reload_marker();
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
diff --git a/crates/jcode-app-core/src/server/client_disconnect_grace_tests.rs b/crates/jcode-app-core/src/server/client_disconnect_grace_tests.rs
new file mode 100644
index 0000000000..bf584a7c89
--- /dev/null
+++ b/crates/jcode-app-core/src/server/client_disconnect_grace_tests.rs
@@ -0,0 +1,292 @@
+#![allow(clippy::await_holding_lock)]
+
+use super::*;
+use crate::protocol::ServerEvent;
+use crate::provider::{EventStream, Provider};
+use crate::session::{Session, SessionStatus};
+use crate::tool::Registry;
+use async_trait::async_trait;
+use std::time::Instant;
+use tokio::time::timeout;
+
+struct NoRequests;
+#[async_trait]
+impl Provider for NoRequests {
+ async fn complete(
+ &self,
+ _: &[crate::message::Message],
+ _: &[crate::message::ToolDefinition],
+ _: &str,
+ _: Option<&str>,
+ ) -> Result {
+ anyhow::bail!("disconnect tests must not request a provider")
+ }
+ fn name(&self) -> &str {
+ "mock"
+ }
+ fn fork(&self) -> Arc {
+ Arc::new(Self)
+ }
+}
+
+struct Home {
+ _dir: tempfile::TempDir,
+ previous: Option,
+}
+impl Home {
+ fn new() -> Self {
+ let dir = tempfile::tempdir().unwrap();
+ let previous = std::env::var_os("JCODE_HOME");
+ crate::env::set_var("JCODE_HOME", dir.path());
+ Self {
+ _dir: dir,
+ previous,
+ }
+ }
+}
+impl Drop for Home {
+ fn drop(&mut self) {
+ match self.previous.take() {
+ Some(value) => crate::env::set_var("JCODE_HOME", value),
+ None => crate::env::remove_var("JCODE_HOME"),
+ }
+ }
+}
+
+struct Fixture {
+ id: String,
+ agent: Arc>,
+ sessions: SessionAgents,
+ members: Arc>>,
+ connections: Arc>>,
+ events: mpsc::UnboundedSender,
+}
+impl Fixture {
+ async fn new(persisted: bool) -> Self {
+ let provider: Arc = Arc::new(NoRequests);
+ let registry = Registry::new(provider.clone()).await;
+ let mut session = Session::create(None, None);
+ if persisted {
+ session.title = Some("Explicit saved panel".into());
+ session.save().unwrap();
+ }
+ let id = session.id.clone();
+ let mut agent = Agent::new_with_session(provider, registry, session, None);
+ agent.set_memory_enabled(false);
+ let agent = Arc::new(Mutex::new(agent));
+ let (events, _) = mpsc::unbounded_channel();
+ let members = Arc::new(RwLock::new(HashMap::from([(
+ id.clone(),
+ SwarmMember {
+ session_id: id.clone(),
+ event_tx: events.clone(),
+ event_txs: HashMap::from([("original".into(), events.clone())]),
+ working_dir: None,
+ swarm_id: None,
+ swarm_enabled: false,
+ status: "ready".into(),
+ detail: None,
+ task_label: None,
+ friendly_name: None,
+ report_back_to_session_id: None,
+ latest_completion_report: None,
+ role: "agent".into(),
+ joined_at: Instant::now(),
+ last_status_change: Instant::now(),
+ is_headless: false,
+ output_tail: None,
+ todo_progress: None,
+ todo_items: Vec::new(),
+ runtime: Default::default(),
+ },
+ )])));
+ let sessions = Arc::new(RwLock::new(HashMap::from([(id.clone(), agent.clone())])));
+ let connections = Arc::new(RwLock::new(HashMap::from([(
+ "original".into(),
+ connection("original", &id),
+ )])));
+ Self {
+ id,
+ agent,
+ sessions,
+ members,
+ connections,
+ events,
+ }
+ }
+
+ async fn cleanup(&self, processing: bool, grace: Duration) {
+ let (swarm_events, _) = broadcast::channel(8);
+ let mut task = None;
+ cleanup_client_connection(
+ &self.sessions,
+ &self.id,
+ processing,
+ &mut task,
+ tokio::spawn(std::future::pending()),
+ &self.members,
+ &Arc::new(RwLock::new(HashMap::new())),
+ &Arc::new(RwLock::new(HashMap::new())),
+ &Arc::new(RwLock::new(HashMap::new())),
+ &FileTouchService::new(),
+ &Arc::new(RwLock::new(HashMap::new())),
+ &Arc::new(RwLock::new(HashMap::new())),
+ &Arc::new(RwLock::new(ClientDebugState::default())),
+ "debug-original",
+ &self.connections,
+ "original",
+ &Arc::new(RwLock::new(HashMap::new())),
+ &Arc::new(RwLock::new(HashMap::new())),
+ &Arc::new(RwLock::new(std::collections::VecDeque::new())),
+ &Arc::new(std::sync::atomic::AtomicU64::new(0)),
+ &swarm_events,
+ &self.events,
+ grace,
+ )
+ .await
+ .unwrap();
+ }
+
+ async fn wait_for_detach(&self) {
+ timeout(Duration::from_secs(1), async {
+ while self.connections.read().await.contains_key("original") {
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("cleanup releases attachment registry promptly");
+ }
+
+ async fn attach_successor(&self) {
+ // Reserve the same live Agent under the same registry lock order used
+ // by claim_live_target_agent, then register the real event attachment.
+ let mut connections = self.connections.write().await;
+ assert!(Arc::ptr_eq(
+ self.sessions.read().await.get(&self.id).unwrap(),
+ &self.agent
+ ));
+ connections.insert("successor".into(), connection("successor", &self.id));
+ drop(connections);
+ let (sender, _) = mpsc::unbounded_channel();
+ crate::server::register_session_event_sender(&self.members, &self.id, "successor", sender)
+ .await;
+ }
+}
+
+fn connection(name: &str, id: &str) -> ClientConnectionInfo {
+ let (disconnect_tx, _) = mpsc::unbounded_channel();
+ ClientConnectionInfo {
+ client_id: name.into(),
+ session_id: id.into(),
+ client_instance_id: None,
+ debug_client_id: None,
+ connected_at: Instant::now(),
+ last_seen: Instant::now(),
+ is_processing: false,
+ current_tool_name: None,
+ terminal_env: vec![],
+ disconnect_tx,
+ }
+}
+
+#[tokio::test]
+async fn unsaved_idle_session_retains_same_agent_for_reattachment() {
+ let _lock = crate::storage::lock_test_env();
+ let _home = Home::new();
+ let fixture = Fixture::new(false).await;
+ let ((), ()) = tokio::join!(fixture.cleanup(false, Duration::from_secs(2)), async {
+ fixture.wait_for_detach().await;
+ assert!(!crate::session::session_exists(&fixture.id));
+ // These locks must remain available during the reconnect grace.
+ let _agent = fixture
+ .agent
+ .try_lock()
+ .expect("grace cannot hold agent lock");
+ drop(_agent);
+ fixture.attach_successor().await;
+ });
+ assert!(Arc::ptr_eq(
+ fixture.sessions.read().await.get(&fixture.id).unwrap(),
+ &fixture.agent
+ ));
+ assert!(fixture.connections.read().await.contains_key("successor"));
+ assert!(fixture.members.read().await.contains_key(&fixture.id));
+ assert!(!crate::session::session_exists(&fixture.id));
+}
+
+#[tokio::test]
+async fn unsaved_idle_session_expires_without_persisting_or_leaking() {
+ let _lock = crate::storage::lock_test_env();
+ let _home = Home::new();
+ let fixture = Fixture::new(false).await;
+ let grace = Duration::from_millis(60);
+ let start = Instant::now();
+ timeout(Duration::from_secs(1), fixture.cleanup(false, grace))
+ .await
+ .unwrap();
+ assert!(start.elapsed() >= grace);
+ assert!(!fixture.sessions.read().await.contains_key(&fixture.id));
+ assert!(!fixture.members.read().await.contains_key(&fixture.id));
+ assert!(fixture.connections.read().await.is_empty());
+ assert!(!crate::session::session_exists(&fixture.id));
+}
+
+#[tokio::test]
+async fn old_grace_cannot_remove_successor_that_already_detached_again() {
+ let _lock = crate::storage::lock_test_env();
+ let _home = Home::new();
+ let fixture = Fixture::new(false).await;
+ tokio::join!(fixture.cleanup(false, Duration::from_millis(100)), async {
+ fixture.wait_for_detach().await;
+ fixture.attach_successor().await;
+ detach_client_attachment(
+ &fixture.id,
+ "successor",
+ "successor-debug",
+ &fixture.connections,
+ &Arc::new(RwLock::new(ClientDebugState::default())),
+ &fixture.members,
+ )
+ .await;
+ });
+ assert!(fixture.connections.read().await.is_empty());
+ assert!(
+ fixture.sessions.read().await.contains_key(&fixture.id),
+ "successor owns its own grace/cleanup"
+ );
+ assert!(fixture.members.read().await.contains_key(&fixture.id));
+}
+
+#[tokio::test]
+async fn persisted_idle_session_does_not_wait_for_reconnect_grace() {
+ let _lock = crate::storage::lock_test_env();
+ let _home = Home::new();
+ let fixture = Fixture::new(true).await;
+ timeout(
+ Duration::from_secs(1),
+ fixture.cleanup(false, Duration::from_secs(30)),
+ )
+ .await
+ .unwrap();
+ assert!(fixture.sessions.read().await.is_empty());
+ assert!(crate::session::session_exists(&fixture.id));
+}
+
+#[tokio::test]
+async fn interrupted_session_does_not_wait_for_reconnect_grace() {
+ let _lock = crate::storage::lock_test_env();
+ let _home = Home::new();
+ crate::server::clear_reload_marker();
+ let fixture = Fixture::new(false).await;
+ timeout(
+ Duration::from_secs(1),
+ fixture.cleanup(true, Duration::from_secs(30)),
+ )
+ .await
+ .unwrap();
+ assert!(fixture.sessions.read().await.is_empty());
+ assert!(matches!(
+ fixture.agent.lock().await.session_for_split().status,
+ SessionStatus::Crashed { .. }
+ ));
+}
diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs
index 68e4f51b83..318d0e300b 100644
--- a/crates/jcode-app-core/src/server/client_lifecycle.rs
+++ b/crates/jcode-app-core/src/server/client_lifecycle.rs
@@ -1,3 +1,4 @@
+use super::available_models_dedup::available_models_dedup_key;
use super::client_actions::{
AgentTaskContext, NotifySessionContext, handle_agent_task, handle_compact, handle_input_shell,
handle_notify_session, handle_rename_session, handle_run_subagent, handle_set_feature,
@@ -9,7 +10,7 @@ use super::client_comm::{
handle_comm_read, handle_comm_share, handle_comm_subscribe_channel,
handle_comm_unsubscribe_channel,
};
-use super::client_disconnect_cleanup::cleanup_client_connection;
+use super::client_disconnect_cleanup::{cleanup_client_connection, detach_client_attachment};
use super::client_lifecycle_logging::{
ServerRequestLifecycleFields, interrupt_request_log_fields, request_payload_summary,
request_type_from_line, request_type_is_read_only, server_request_lifecycle_fields,
@@ -89,20 +90,92 @@ fn required_subscribe_working_dir(working_dir: Option<&str>) -> std::result::Res
fn initial_subscribe_working_dir(request: &Request) -> std::result::Result {
match request {
- Request::Subscribe { working_dir, .. } => {
- required_subscribe_working_dir(working_dir.as_deref()).map(str::to_string)
- }
+ Request::Subscribe {
+ working_dir,
+ continue_on_disconnect,
+ ..
+ } => validated_subscribe_working_dir(working_dir.as_deref(), *continue_on_disconnect)
+ .map(str::to_string),
_ => Err(
"Client must Subscribe with a working_dir before sending stateful requests".to_string(),
),
}
}
+/// A reattachment names an existing session, not a new client working directory.
+/// Resolve an omitted cwd before provisional initialization, never from the
+/// daemon/bridge process cwd. Idle empty sessions may exist only in memory.
+async fn resolve_target_subscribe_working_dir(
+ request: &mut Request,
+ sessions: &SessionAgents,
+ members: &Arc>>,
+) -> std::result::Result<(), String> {
+ let Request::Subscribe {
+ working_dir,
+ target_session_id: Some(target),
+ ..
+ } = request
+ else {
+ return Ok(());
+ };
+ if working_dir.is_some() {
+ return Ok(());
+ }
+ let live = sessions.read().await.get(target).cloned();
+ let resolved = if let Some(live) = live {
+ let idle_cwd = live
+ .try_lock()
+ .ok()
+ .and_then(|agent| agent.working_dir().map(str::to_string));
+ if idle_cwd.is_some() {
+ idle_cwd
+ } else {
+ // A generating Agent owns its mutex. The member records the same
+ // session root, so attaching must not wait for the model turn.
+ members
+ .read()
+ .await
+ .get(target)
+ .and_then(|member| member.working_dir.as_ref())
+ .map(|path| path.to_string_lossy().into_owned())
+ }
+ } else {
+ crate::session::Session::load_startup_stub(target)
+ .ok()
+ .and_then(|session| session.working_dir)
+ };
+ *working_dir = Some(resolved.ok_or_else(|| {
+ format!("Unknown session '{target}' or session has no working directory")
+ })?);
+ Ok(())
+}
+
+fn validated_subscribe_working_dir(
+ working_dir: Option<&str>,
+ remote_continuation: bool,
+) -> std::result::Result<&str, String> {
+ let working_dir = required_subscribe_working_dir(working_dir)?;
+ if remote_continuation && !Path::new(working_dir).is_dir() {
+ return Err(format!(
+ "Remote working directory must exist and be a directory on the server: {working_dir}"
+ ));
+ }
+ Ok(working_dir)
+}
+
+fn initial_subscribe_terminal_env(request: &Request) -> Vec<(String, String)> {
+ match request {
+ Request::Subscribe { terminal_env, .. } => terminal_env.clone(),
+ _ => Vec::new(),
+ }
+}
+
struct ProcessingMessage {
id: u64,
content: String,
images: Vec<(String, String)>,
system_reminder: Option,
+ active_skill: Option,
}
struct ProcessingState<'a> {
@@ -120,6 +193,14 @@ struct SwarmStatusRefs<'a> {
event_tx: &'a broadcast::Sender,
}
+fn should_start_idle_soft_interrupt(
+ client_is_processing: bool,
+ active_turn_registered: bool,
+ session_connection_busy: bool,
+) -> bool {
+ !client_is_processing && !active_turn_registered && !session_connection_busy
+}
+
struct RequestHandlerWatchdog {
done: Arc,
}
@@ -224,6 +305,23 @@ fn reject_if_agent_busy_for_request(
return false;
}
+ send_agent_busy_error(
+ request_id,
+ request_kind,
+ client_session_id,
+ client_is_processing,
+ client_event_tx,
+ );
+ true
+}
+
+fn send_agent_busy_error(
+ request_id: u64,
+ request_kind: &'static str,
+ client_session_id: &str,
+ client_is_processing: bool,
+ client_event_tx: &mpsc::UnboundedSender,
+) {
crate::logging::event_warn(
"SERVER_REQUEST_BUSY_AGENT_REJECTED",
vec![
@@ -241,7 +339,6 @@ fn reject_if_agent_busy_for_request(
),
retry_after_secs: Some(1),
});
- true
}
fn server_reload_starting() -> bool {
@@ -370,7 +467,7 @@ pub(super) async fn handle_client(
let writer = Arc::new(Mutex::new(writer));
let mut line = String::new();
- let initial_request = loop {
+ let mut initial_request = loop {
line.clear();
let n = match reader.read_line(&mut line).await {
Ok(n) => n,
@@ -392,6 +489,7 @@ pub(super) async fn handle_client(
match decode_request(&line) {
Ok(request) => {
if request.is_lightweight_control_request() {
+ let keep_connection_open = matches!(request, Request::Ping { .. });
handle_lightweight_control_request(
request,
Arc::clone(&writer),
@@ -418,6 +516,12 @@ pub(super) async fn handle_client(
},
)
.await?;
+ // Native SSH probes daemon capability before sending its
+ // Subscribe on this same stream. Ping must not consume the
+ // connection, unlike the other one-shot control requests.
+ if keep_connection_open {
+ continue;
+ }
return Ok(());
}
break request;
@@ -436,21 +540,26 @@ pub(super) async fn handle_client(
}
};
- let initial_working_dir = match initial_subscribe_working_dir(&initial_request) {
- Ok(working_dir) => working_dir,
- Err(message) => {
- write_direct_event(
- &writer,
- &ServerEvent::Error {
- id: initial_request.id(),
- message,
- retry_after_secs: None,
- },
- )
- .await?;
- return Ok(());
- }
- };
+ let initial_working_dir =
+ match resolve_target_subscribe_working_dir(&mut initial_request, &sessions, &swarm_members)
+ .await
+ .and_then(|()| initial_subscribe_working_dir(&initial_request))
+ {
+ Ok(working_dir) => working_dir,
+ Err(message) => {
+ write_direct_event(
+ &writer,
+ &ServerEvent::Error {
+ id: initial_request.id(),
+ message,
+ retry_after_secs: None,
+ },
+ )
+ .await?;
+ return Ok(());
+ }
+ };
+ let mut active_terminal_env = initial_subscribe_terminal_env(&initial_request);
// Per-client state
let mut client_is_processing = false;
@@ -460,12 +569,15 @@ pub(super) async fn handle_client(
let mut processing_message_id: Option = None;
let mut processing_session_id: Option = None;
let mut current_client_instance_id: Option = None;
+ let mut continue_on_disconnect = false;
+ let mut model_usage_updates_enabled = false;
// Client selfdev status is determined by Subscribe request, not server's env
let mut client_selfdev = false;
let client_start = std::time::Instant::now();
let provider = provider_template.fork_for_new_session();
+ let provider_fork_ms = client_start.elapsed().as_millis();
let t0 = std::time::Instant::now();
let registry = Registry::new(provider.clone()).await;
let registry_ms = t0.elapsed().as_millis();
@@ -476,17 +588,24 @@ pub(super) async fn handle_client(
// Create a new session for this client
let t0 = std::time::Instant::now();
- let mut new_agent = Agent::new_with_initial_working_dir(
- Arc::clone(&provider),
- registry.clone(),
- Some(&initial_working_dir),
- );
+ let mut new_agent =
+ crate::hooks::with_client_terminal_env(active_terminal_env.clone(), async {
+ Agent::new_provisional_with_initial_working_dir(
+ Arc::clone(&provider),
+ registry.clone(),
+ Some(&initial_working_dir),
+ )
+ })
+ .await;
let agent_new_ms = t0.elapsed().as_millis();
new_agent.set_memory_enabled(crate::config::config().features.memory);
+ let prewarm_start = std::time::Instant::now();
+ new_agent.prewarm_provider_idle().await;
+ let prewarm_ms = prewarm_start.elapsed().as_millis();
crate::logging::info(&format!(
- "[TIMING] handle_client setup: registry={registry_ms}ms, agent_new={agent_new_ms}ms, total={}ms",
+ "[TIMING] handle_client setup: provider_fork={provider_fork_ms}ms, registry={registry_ms}ms, agent_new={agent_new_ms}ms, prewarm={prewarm_ms}ms, total={}ms",
client_start.elapsed().as_millis()
));
let mut client_session_id = new_agent.session_id().to_string();
@@ -508,7 +627,7 @@ pub(super) async fn handle_client(
last_seen: connected_at,
is_processing: false,
current_tool_name: None,
- terminal_env: Vec::new(),
+ terminal_env: active_terminal_env.clone(),
disconnect_tx: disconnect_tx.clone(),
},
);
@@ -638,9 +757,9 @@ pub(super) async fn handle_client(
tokio::sync::mpsc::unbounded_channel::();
{
let mut agent_guard = agent.lock().await;
- agent_guard.set_stdin_request_tx(stdin_req_tx);
+ agent_guard.set_stdin_request_tx(stdin_req_tx.clone());
}
- let _stdin_forwarder = {
+ let stdin_forwarder = {
let client_event_tx = client_event_tx.clone();
let stdin_responses = stdin_responses.clone();
let tool_call_id = String::new();
@@ -665,10 +784,12 @@ pub(super) async fn handle_client(
// subscribe. Under heavy swarm file-activity load, ignored bus frames can
// otherwise monopolize the select loop before the initial subscribe/read.
let mut client_subscribed = false;
+ let mut provisional_session = true;
let mut pending_request = Some(initial_request);
+ let connection_result: Result<()> = async {
loop {
- let request = if let Some(request) = pending_request.take() {
+ let mut request = if let Some(request) = pending_request.take() {
request
} else {
line.clear();
@@ -718,58 +839,16 @@ pub(super) async fn handle_client(
}
let done_session = processing_session_id.take();
- match result {
- Ok(()) => {
- if let Some(session_id) = done_session.as_deref() {
- update_member_status_with_report(
- session_id,
- "ready",
- None,
- completion_report,
- &swarm_members,
- &swarms_by_id,
- Some(&event_history),
- Some(&event_counter),
- Some(&swarm_event_tx),
- )
- .await;
- }
- }
- Err(e) => {
- if let Some(session_id) = done_session.as_deref() {
- update_member_status(
- session_id,
- "failed",
- Some(truncate_detail(&e.to_string(), 120)),
- &swarm_members,
- &swarms_by_id,
- Some(&event_history),
- Some(&event_counter),
- Some(&swarm_event_tx),
- )
- .await;
- }
- let retry_after_secs = e.downcast_ref::().and_then(|se| se.retry_after_secs);
- if retry_after_secs.is_some() {
- crate::telemetry::record_error(crate::telemetry::ErrorCategory::RateLimited);
- } else {
- let msg = e.to_string();
- let lower = msg.to_lowercase();
- if lower.contains("timeout") {
- crate::telemetry::record_error(crate::telemetry::ErrorCategory::ProviderTimeout);
- } else if crate::provider::error_looks_like_credential_failure(&msg)
- || lower.contains("403 forbidden")
- {
- // Use the shared credential-failure classifier instead of a
- // bare `contains("auth")`: that substring also matched
- // unrelated errors (e.g. any message mentioning "author" or
- // OAuth flow noise) and inflated the auth_failed telemetry
- // counter.
- crate::telemetry::record_error(crate::telemetry::ErrorCategory::AuthFailed);
- }
- }
- }
- }
+ record_processing_completion(
+ done_session.as_deref(), result, completion_report,
+ &SwarmStatusRefs {
+ members: &swarm_members,
+ swarms_by_id: &swarms_by_id,
+ event_history: &event_history,
+ event_counter: &event_counter,
+ event_tx: &swarm_event_tx,
+ },
+ ).await;
} else {
break;
}
@@ -788,6 +867,11 @@ pub(super) async fn handle_client(
// Forward bus events to this client
bus_event = bus_rx.recv(), if client_subscribed => {
match bus_event {
+ Ok(BusEvent::ModelUsageUpdated(route)) => {
+ if model_usage_updates_enabled {
+ let _ = client_event_tx.send(ServerEvent::ModelUsageUpdated { route });
+ }
+ }
Ok(BusEvent::ModelsUpdated) => {
let Some(event) = try_available_models_updated_event(&agent) else {
crate::logging::info(&format!(
@@ -796,11 +880,16 @@ pub(super) async fn handle_client(
));
continue;
};
- let encoded_event = crate::protocol::encode_event(&event);
- if last_available_models_snapshot.as_ref() == Some(&encoded_event) {
+ // Compare on an age-insensitive key: route details carry
+ // cosmetic "12m ago" cache ages that tick on their own,
+ // and a raw byte compare treated that drift as a real
+ // catalog change, fanning a full repaint out to every
+ // connected client.
+ let dedup_key = available_models_dedup_key(&event);
+ if last_available_models_snapshot.as_ref() == Some(&dedup_key) {
continue;
}
- let encoded_len = encoded_event.len();
+ let encoded_len = crate::protocol::encode_event(&event).len();
if encoded_len > MAX_LIVE_AVAILABLE_MODELS_UPDATE_BYTES {
// Don't drop the catalog update entirely: clients still
// need fresh model names for the picker. Strip the heavy
@@ -829,11 +918,11 @@ pub(super) async fn handle_client(
));
}
}
- last_available_models_snapshot = Some(encoded_event);
+ last_available_models_snapshot = Some(dedup_key);
continue;
}
let _ = client_event_tx.send(event);
- last_available_models_snapshot = Some(encoded_event);
+ last_available_models_snapshot = Some(dedup_key);
}
Ok(BusEvent::BatchProgress(progress)) => {
if progress.session_id == client_session_id {
@@ -1065,14 +1154,57 @@ pub(super) async fn handle_client(
}
}
+ // Legacy/direct clients can send a prompt without Subscribe. Their
+ // first session action commits ownership, but inspection/attach does not.
+ if provisional_session
+ && matches!(
+ &request,
+ Request::Message { .. }
+ | Request::SoftInterrupt { .. }
+ | Request::RunSubagent { .. }
+ )
+ {
+ agent.lock().await.activate_concurrency_tracking();
+ provisional_session = false;
+ }
+
+ if let Err(message) = resolve_target_subscribe_working_dir(
+ &mut request, &sessions, &swarm_members,
+ ).await {
+ let _ = client_event_tx.send(ServerEvent::Error {
+ id: request.id(), message, retry_after_secs: None,
+ });
+ continue;
+ }
match request {
Request::Message {
id,
content,
images,
system_reminder,
+ active_skill,
+ no_reply,
} => {
+ if no_reply {
+ append_context_message(
+ id,
+ &content,
+ images,
+ &client_session_id,
+ client_is_processing,
+ &agent,
+ &client_event_tx,
+ )
+ .await;
+ continue;
+ }
if !client_is_processing {
+ // A live resume cannot replace stdin routing while the old
+ // turn owns the agent. Restore it when this client starts a
+ // later turn, without reviving any disconnected prompt.
+ if continue_on_disconnect && let Ok(mut agent) = agent.try_lock() {
+ agent.set_stdin_request_tx(stdin_req_tx.clone());
+ }
let mut connections = client_connections.write().await;
if let Some(info) = connections.get_mut(&client_connection_id) {
info.is_processing = true;
@@ -1085,6 +1217,7 @@ pub(super) async fn handle_client(
content,
images,
system_reminder,
+ active_skill,
},
&client_session_id,
&mut ProcessingState {
@@ -1096,6 +1229,7 @@ pub(super) async fn handle_client(
&agent,
&client_event_tx,
&processing_done_tx,
+ active_terminal_env.clone(),
&SwarmStatusRefs {
members: &swarm_members,
swarms_by_id: &swarms_by_id,
@@ -1140,16 +1274,82 @@ pub(super) async fn handle_client(
Request::SoftInterrupt {
id,
content,
+ images,
urgent,
} => {
- queue_soft_interrupt(
- id,
- content,
- urgent,
- SoftInterruptSource::User,
- &session_control,
- &client_event_tx,
- );
+ // A soft interrupt has somewhere to go only while a turn is
+ // active. When the session is idle, queueing it would strand
+ // the user's prompt until an unrelated future message starts
+ // a turn. Claim the idle session and process it as the next
+ // user message instead. The connection-map claim is atomic
+ // with the cross-client busy check, so two attachments cannot
+ // both decide that the same session is idle.
+ let start_idle_turn = {
+ let mut connections = client_connections.write().await;
+ let active_turn_registered =
+ !crate::turn_cancel_registry::active_turn_signals(&client_session_id)
+ .is_empty();
+ let session_connection_busy = connections
+ .values()
+ .any(|info| info.session_id == client_session_id && info.is_processing);
+ let start = should_start_idle_soft_interrupt(
+ client_is_processing,
+ active_turn_registered,
+ session_connection_busy,
+ );
+ if start {
+ if let Some(info) = connections.get_mut(&client_connection_id) {
+ info.is_processing = true;
+ }
+ }
+ start
+ };
+ if start_idle_turn {
+ start_processing_message(
+ ProcessingMessage {
+ id,
+ content,
+ images,
+ system_reminder: None,
+ active_skill: None,
+ },
+ &client_session_id,
+ &mut ProcessingState {
+ client_is_processing: &mut client_is_processing,
+ message_id: &mut processing_message_id,
+ session_id: &mut processing_session_id,
+ task: &mut processing_task,
+ },
+ &agent,
+ &client_event_tx,
+ &processing_done_tx,
+ active_terminal_env.clone(),
+ &SwarmStatusRefs {
+ members: &swarm_members,
+ swarms_by_id: &swarms_by_id,
+ event_history: &event_history,
+ event_counter: &event_counter,
+ event_tx: &swarm_event_tx,
+ },
+ )
+ .await;
+ if !client_is_processing {
+ let mut connections = client_connections.write().await;
+ if let Some(info) = connections.get_mut(&client_connection_id) {
+ info.is_processing = false;
+ }
+ }
+ } else {
+ queue_soft_interrupt(
+ id,
+ content,
+ images,
+ urgent,
+ SoftInterruptSource::User,
+ &session_control,
+ &client_event_tx,
+ );
+ }
}
Request::CancelSoftInterrupts { id } => {
@@ -1171,28 +1371,31 @@ pub(super) async fn handle_client(
) {
continue;
}
- handle_clear_session(
- id,
- client_selfdev,
- &mut client_session_id,
- &client_connection_id,
- &agent,
- &provider,
- ®istry,
- &sessions,
- &shutdown_signals,
- &soft_interrupt_queues,
- &client_connections,
- &swarm_members,
- &swarms_by_id,
- &file_touch,
- &channel_subscriptions,
- &channel_subscriptions_by_session,
- &swarm_plans,
- &event_history,
- &event_counter,
- &swarm_event_tx,
- &client_event_tx,
+ crate::hooks::with_client_terminal_env(
+ active_terminal_env.clone(),
+ handle_clear_session(
+ id,
+ client_selfdev,
+ &mut client_session_id,
+ &client_connection_id,
+ &agent,
+ &provider,
+ ®istry,
+ &sessions,
+ &shutdown_signals,
+ &soft_interrupt_queues,
+ &client_connections,
+ &swarm_members,
+ &swarms_by_id,
+ &file_touch,
+ &channel_subscriptions,
+ &channel_subscriptions_by_session,
+ &swarm_plans,
+ &event_history,
+ &event_counter,
+ &swarm_event_tx,
+ &client_event_tx,
+ ),
)
.await;
session_control = refresh_session_control_handle(
@@ -1324,7 +1527,15 @@ pub(super) async fn handle_client(
}
Request::Ping { id } => {
- let json = encode_event(&ServerEvent::Pong { id });
+ let json = encode_event(&ServerEvent::Pong { id, native_ssh_protocol: Some(1) });
+ let mut w = writer.lock().await;
+ if w.write_all(json.as_bytes()).await.is_err() {
+ break;
+ }
+ }
+
+ Request::PrepareDisconnect { id } => {
+ let json = encode_event(&ServerEvent::Done { id });
let mut w = writer.lock().await;
if w.write_all(json.as_bytes()).await.is_err() {
break;
@@ -1354,10 +1565,14 @@ pub(super) async fn handle_client(
client_instance_id,
client_has_local_history,
allow_session_takeover,
+ crash_on_disconnect: _,
+ continue_on_disconnect: requested_continuation,
terminal_env,
} => {
if let Err(message) =
- required_subscribe_working_dir(subscribe_working_dir.as_deref())
+ validated_subscribe_working_dir(
+ subscribe_working_dir.as_deref(), requested_continuation,
+ )
{
let _ = client_event_tx.send(ServerEvent::Error {
id,
@@ -1366,57 +1581,68 @@ pub(super) async fn handle_client(
});
continue;
}
+ // Every Subscribe carries an authoritative snapshot. An empty
+ // snapshot must clear terminal vars inherited by the daemon
+ // rather than retaining a prior pane's values.
+ continue_on_disconnect = requested_continuation;
+ active_terminal_env = terminal_env;
current_client_instance_id = client_instance_id.clone();
{
let mut connections = client_connections.write().await;
if let Some(info) = connections.get_mut(&client_connection_id) {
info.client_instance_id = client_instance_id.clone();
- // Record the client's terminal env so spawn/focus hooks
- // target the client's terminal, not the server's stale
- // startup env (#405). Only overwrite when the client sent
- // something, so reconnects without env don't clobber it.
- if !terminal_env.is_empty() {
- info.terminal_env = terminal_env.clone();
- }
+ info.terminal_env = active_terminal_env.clone();
}
}
if let Some(target_session_id) = target_session_id {
- if crate::session::session_exists(&target_session_id) {
+ // A brand-new desktop panel has no transcript on disk until
+ // its first prompt. Its creator connection can detach before
+ // the panel connection arrives, while the live agent is
+ // already registered in memory. Treat that as an existing
+ // session or the target-aware subscribe silently creates a
+ // different session and every subsequent command reports a
+ // wrong-session attachment.
+ if crate::session::session_exists(&target_session_id)
+ || sessions.read().await.contains_key(&target_session_id)
+ {
let pre_resume_session_id = client_session_id.clone();
- agent = handle_resume_session(
- id,
- target_session_id.clone(),
- subscribe_working_dir.as_deref(),
- client_instance_id.as_deref(),
- client_has_local_history,
- allow_session_takeover,
- &mut client_selfdev,
- &mut client_session_id,
- &client_connection_id,
- &agent,
- &provider,
- ®istry,
- &sessions,
- &shutdown_signals,
- &soft_interrupt_queues,
- &client_connections,
- &client_debug_state,
- &swarm_members,
- &swarms_by_id,
- &file_touch,
- &channel_subscriptions,
- &channel_subscriptions_by_session,
- &swarm_plans,
- &swarm_coordinators,
- &client_count,
- &writer,
- &server_name,
- &server_icon,
- &client_event_tx,
- &mcp_pool,
- &event_history,
- &event_counter,
- &swarm_event_tx,
+ agent = crate::hooks::with_client_terminal_env(
+ active_terminal_env.clone(),
+ handle_resume_session(
+ id,
+ target_session_id.clone(),
+ subscribe_working_dir.as_deref(),
+ client_instance_id.as_deref(),
+ client_has_local_history,
+ allow_session_takeover,
+ &mut client_selfdev,
+ &mut client_session_id,
+ &client_connection_id,
+ &agent,
+ &provider,
+ ®istry,
+ &sessions,
+ &shutdown_signals,
+ &soft_interrupt_queues,
+ &client_connections,
+ &client_debug_state,
+ &swarm_members,
+ &swarms_by_id,
+ &file_touch,
+ &channel_subscriptions,
+ &channel_subscriptions_by_session,
+ &swarm_plans,
+ &swarm_coordinators,
+ &client_count,
+ &writer,
+ &server_name,
+ &server_icon,
+ &client_event_tx,
+ &mcp_pool,
+ &event_history,
+ &event_counter,
+ &swarm_event_tx,
+ ),
)
.await?;
session_control = refresh_session_control_handle(
@@ -1463,6 +1689,9 @@ pub(super) async fn handle_client(
break;
}
} else {
+ if provisional_session {
+ agent.lock().await.activate_concurrency_tracking();
+ }
handle_subscribe(
id,
subscribe_working_dir,
@@ -1490,6 +1719,9 @@ pub(super) async fn handle_client(
.await;
}
} else {
+ if provisional_session {
+ agent.lock().await.activate_concurrency_tracking();
+ }
handle_subscribe(
id,
subscribe_working_dir,
@@ -1520,6 +1752,7 @@ pub(super) async fn handle_client(
}
}
client_subscribed = true;
+ provisional_session = false;
}
Request::GetHistory { id } => {
@@ -1553,7 +1786,8 @@ pub(super) async fn handle_client(
}
}
- Request::GetModelCatalog { id } => {
+ Request::GetModelCatalog { id, subscribe_usage_updates } => {
+ model_usage_updates_enabled = subscribe_usage_updates;
if handle_get_model_catalog(id, &client_session_id, &agent, &provider, &writer)
.await
.is_err()
@@ -1610,6 +1844,7 @@ pub(super) async fn handle_client(
client_has_local_history,
allow_session_takeover,
} => {
+ let pre_resume_session_id = client_session_id.clone();
let resume_working_dir = {
let agent_guard = agent.lock().await;
agent_guard.working_dir().map(str::to_string)
@@ -1621,42 +1856,48 @@ pub(super) async fn handle_client(
info.client_instance_id = client_instance_id.clone();
}
}
- agent = handle_resume_session(
- id,
- session_id,
- resume_working_dir.as_deref(),
- client_instance_id.as_deref(),
- client_has_local_history,
- allow_session_takeover,
- &mut client_selfdev,
- &mut client_session_id,
- &client_connection_id,
- &agent,
- &provider,
- ®istry,
- &sessions,
- &shutdown_signals,
- &soft_interrupt_queues,
- &client_connections,
- &client_debug_state,
- &swarm_members,
- &swarms_by_id,
- &file_touch,
- &channel_subscriptions,
- &channel_subscriptions_by_session,
- &swarm_plans,
- &swarm_coordinators,
- &client_count,
- &writer,
- &server_name,
- &server_icon,
- &client_event_tx,
- &mcp_pool,
- &event_history,
- &event_counter,
- &swarm_event_tx,
+ agent = crate::hooks::with_client_terminal_env(
+ active_terminal_env.clone(),
+ handle_resume_session(
+ id,
+ session_id,
+ resume_working_dir.as_deref(),
+ client_instance_id.as_deref(),
+ client_has_local_history,
+ allow_session_takeover,
+ &mut client_selfdev,
+ &mut client_session_id,
+ &client_connection_id,
+ &agent,
+ &provider,
+ ®istry,
+ &sessions,
+ &shutdown_signals,
+ &soft_interrupt_queues,
+ &client_connections,
+ &client_debug_state,
+ &swarm_members,
+ &swarms_by_id,
+ &file_touch,
+ &channel_subscriptions,
+ &channel_subscriptions_by_session,
+ &swarm_plans,
+ &swarm_coordinators,
+ &client_count,
+ &writer,
+ &server_name,
+ &server_icon,
+ &client_event_tx,
+ &mcp_pool,
+ &event_history,
+ &event_counter,
+ &swarm_event_tx,
+ ),
)
.await?;
+ if client_session_id != pre_resume_session_id {
+ provisional_session = false;
+ }
session_control = refresh_session_control_handle(
&client_session_id,
&agent,
@@ -1855,7 +2096,7 @@ pub(super) async fn handle_client(
}
Request::Split { id } => {
- handle_split(id, &client_session_id, &client_event_tx).await;
+ handle_split(id, &client_session_id, &agent, &client_event_tx).await;
}
Request::Transfer { id } => {
@@ -2715,33 +2956,196 @@ pub(super) async fn handle_client(
}
}
- cleanup_client_connection(
- &sessions,
- &client_session_id,
- client_is_processing,
- &mut processing_task,
- event_handle,
- &swarm_members,
- &swarms_by_id,
- &swarm_coordinators,
- &swarm_plans,
- &file_touch,
- &channel_subscriptions,
- &channel_subscriptions_by_session,
- &client_debug_state,
- &client_debug_id,
- &client_connections,
- &client_connection_id,
- &shutdown_signals,
- &soft_interrupt_queues,
- &event_history,
- &event_counter,
- &swarm_event_tx,
+ Ok(())
+ }.await;
+
+ if continue_on_disconnect {
+ // Retain the existing turn owner, not the socket. Its JoinHandle and
+ // completion receiver stay alive so normal finalization still runs and
+ // the daemon cannot idle-shutdown midway through remote work. New
+ // attachments receive future events through the existing session fanout.
+ detach_client_attachment(
+ &client_session_id,
+ &client_connection_id,
+ &client_debug_id,
+ &client_connections,
+ &client_debug_state,
+ &swarm_members,
+ )
+ .await;
+ event_handle.abort();
+ drop(reader);
+ drop(writer);
+ // Input prompts belong to this transport and cannot safely be replayed
+ // to a new client. Close response channels instead of waiting forever.
+ stdin_forwarder.abort();
+ let _ = stdin_forwarder.await;
+ stdin_responses.lock().await.clear();
+ if let Some(handle) = processing_task.take() {
+ crate::logging::info(&format!(
+ "Retaining disconnected remote turn for session {}",
+ client_session_id
+ ));
+ let _ = handle.await;
+ while let Ok((done_id, result, report)) = processing_done_rx.try_recv() {
+ if Some(done_id) == processing_message_id {
+ record_processing_completion(
+ processing_session_id.as_deref(),
+ result,
+ report,
+ &SwarmStatusRefs {
+ members: &swarm_members,
+ swarms_by_id: &swarms_by_id,
+ event_history: &event_history,
+ event_counter: &event_counter,
+ event_tx: &swarm_event_tx,
+ },
+ )
+ .await;
+ }
+ }
+ client_is_processing = false;
+ } else {
+ // A reattached remote connection may disconnect again while the
+ // original lifecycle owns the task. Wait for its active-turn lease
+ // before attempting cleanup. Returning early here would leak the
+ // session if the original owner had just skipped cleanup for this
+ // successor. All finishers serialize cleanup against live attach.
+ while crate::turn_cancel_registry::has_active_turn(&client_session_id) {
+ tokio::time::sleep(Duration::from_millis(25)).await;
+ }
+ client_is_processing = false;
+ }
+ }
+
+ crate::hooks::with_client_terminal_env(
+ active_terminal_env,
+ cleanup_client_connection(
+ &sessions,
+ &client_session_id,
+ client_is_processing,
+ &mut processing_task,
+ event_handle,
+ &swarm_members,
+ &swarms_by_id,
+ &swarm_coordinators,
+ &swarm_plans,
+ &file_touch,
+ &channel_subscriptions,
+ &channel_subscriptions_by_session,
+ &client_debug_state,
+ &client_debug_id,
+ &client_connections,
+ &client_connection_id,
+ &shutdown_signals,
+ &soft_interrupt_queues,
+ &event_history,
+ &event_counter,
+ &swarm_event_tx,
+ &client_event_tx,
+ super::client_disconnect_cleanup::IDLE_RECONNECT_GRACE,
+ ),
)
.await?;
- Ok(())
+ connection_result
+}
+
+async fn record_processing_completion(
+ done_session: Option<&str>,
+ result: Result<()>,
+ completion_report: Option,
+ swarm: &SwarmStatusRefs<'_>,
+) {
+ match result {
+ Ok(()) => {
+ if let Some(session_id) = done_session {
+ update_member_status_with_report(
+ session_id,
+ "ready",
+ None,
+ completion_report,
+ swarm.members,
+ swarm.swarms_by_id,
+ Some(swarm.event_history),
+ Some(swarm.event_counter),
+ Some(swarm.event_tx),
+ )
+ .await;
+ }
+ }
+ Err(e) => {
+ if let Some(session_id) = done_session {
+ update_member_status(
+ session_id,
+ "failed",
+ Some(truncate_detail(&e.to_string(), 120)),
+ swarm.members,
+ swarm.swarms_by_id,
+ Some(swarm.event_history),
+ Some(swarm.event_counter),
+ Some(swarm.event_tx),
+ )
+ .await;
+ }
+ let retry_after_secs = e
+ .downcast_ref::()
+ .and_then(|se| se.retry_after_secs);
+ if retry_after_secs.is_some() {
+ crate::telemetry::record_error(crate::telemetry::ErrorCategory::RateLimited);
+ } else {
+ let msg = e.to_string();
+ let lower = msg.to_lowercase();
+ if lower.contains("timeout") {
+ crate::telemetry::record_error(
+ crate::telemetry::ErrorCategory::ProviderTimeout,
+ );
+ } else if crate::provider::error_looks_like_credential_failure(&msg)
+ || lower.contains("403 forbidden")
+ {
+ // Use the shared credential-failure classifier instead of a
+ // bare `contains("auth")`: that substring also matched
+ // unrelated errors (e.g. any message mentioning "author" or
+ // OAuth flow noise) and inflated the auth_failed telemetry
+ // counter.
+ crate::telemetry::record_error(crate::telemetry::ErrorCategory::AuthFailed);
+ }
+ }
+ }
+ }
+}
+
+async fn append_context_message(
+ id: u64,
+ content: &str,
+ images: Vec<(String, String)>,
+ client_session_id: &str,
+ client_is_processing: bool,
+ agent: &Arc>,
+ client_event_tx: &mpsc::UnboundedSender,
+) {
+ let Ok(mut agent) = agent.try_lock() else {
+ send_agent_busy_error(
+ id,
+ "context_message",
+ client_session_id,
+ client_is_processing,
+ client_event_tx,
+ );
+ return;
+ };
+ let result = agent.append_user_context_message(content, images);
+ let event = match result {
+ Ok(()) => ServerEvent::ContextMessageAdded { id },
+ Err(error) => ServerEvent::Error {
+ id,
+ message: crate::util::format_error_chain(&error),
+ retry_after_secs: None,
+ },
+ };
+ let _ = client_event_tx.send(event);
}
+#[allow(clippy::too_many_arguments)]
async fn start_processing_message(
message: ProcessingMessage,
client_session_id: &str,
@@ -2749,6 +3153,7 @@ async fn start_processing_message(
agent: &Arc>,
client_event_tx: &mpsc::UnboundedSender,
processing_done_tx: &mpsc::UnboundedSender<(u64, Result<()>, Option)>,
+ client_terminal_env: Vec<(String, String)>,
swarm: &SwarmStatusRefs<'_>,
) {
let ProcessingMessage {
@@ -2756,6 +3161,7 @@ async fn start_processing_message(
content,
images,
system_reminder,
+ active_skill,
} = message;
if server_reload_starting() {
crate::logging::info(&format!(
@@ -2775,6 +3181,20 @@ async fn start_processing_message(
return;
}
+ if !agent
+ .lock()
+ .await
+ .set_remote_active_skill(active_skill.clone())
+ {
+ let skill_name = active_skill.as_deref().unwrap_or_default();
+ let _ = client_event_tx.send(ServerEvent::Error {
+ id,
+ message: format!("Skill '{skill_name}' is not installed on the server"),
+ retry_after_secs: None,
+ });
+ return;
+ }
+
*state.client_is_processing = true;
*state.message_id = Some(id);
*state.session_id = Some(client_session_id.to_string());
@@ -2819,12 +3239,9 @@ async fn start_processing_message(
crate::logging::info(&format!("Processing message id={} spawning task", id));
*state.task = Some(tokio::spawn(async move {
let event_tx = tx.clone();
- let result = match std::panic::AssertUnwindSafe(process_message_streaming_mpsc(
- agent,
- &content,
- images,
- system_reminder,
- event_tx,
+ let result = match std::panic::AssertUnwindSafe(crate::hooks::with_client_terminal_env(
+ client_terminal_env,
+ process_message_streaming_mpsc(agent, &content, images, system_reminder, event_tx),
))
.catch_unwind()
.await
@@ -2994,6 +3411,25 @@ async fn cancel_processing_message(
*state.client_is_processing,
*state.message_id
));
+ // Nothing is running anywhere for this session, so there is no turn to
+ // interrupt and arming the signal can only harm the *next* one: the
+ // deferred reset below runs 500ms later, and a message sent inside
+ // that window starts with the cancel flag already set and dies
+ // immediately, with no reply and no error. Report the interrupt and
+ // stop. Sessions whose turn is owned by another connection still take
+ // the signalling path, since the registry sees those turns.
+ if !crate::turn_cancel_registry::has_active_turn(&session_control.session_id) {
+ crate::logging::info(&format!(
+ "SERVER_INTERRUPT_CANCEL_IDLE_NOOP request_id={:?} session={}",
+ request_id, session_label
+ ));
+ *state.client_is_processing = false;
+ let _ = client_event_tx.send(ServerEvent::Interrupted);
+ if let Some(message_id) = state.message_id.take() {
+ let _ = client_event_tx.send(ServerEvent::Done { id: message_id });
+ }
+ return;
+ }
let cancel_epoch = session_control.request_cancel();
let reset_control = session_control.clone();
tokio::spawn(async move {
@@ -3045,7 +3481,7 @@ async fn cancel_processing_message(
fn try_available_models_snapshot(agent: &Arc>) -> Option {
let event = try_available_models_updated_event(agent)?;
- Some(crate::protocol::encode_event(&event))
+ Some(available_models_dedup_key(&event))
}
/// Build a names-only copy of an `AvailableModelsUpdated` event by dropping the
@@ -3072,6 +3508,7 @@ fn names_only_available_models_event(event: &ServerEvent) -> Option
fn queue_soft_interrupt(
id: u64,
content: String,
+ images: Vec<(String, String)>,
urgent: bool,
source: SoftInterruptSource,
session_control: &SessionControlHandle,
@@ -3083,7 +3520,7 @@ fn queue_soft_interrupt(
"SERVER_SOFT_INTERRUPT_QUEUE_REQUEST id={} session={} source={:?} urgent={} content_bytes={} content_chars={}",
id, session_control.session_id, source, urgent, content_bytes, content_chars
));
- let queued = session_control.queue_soft_interrupt(content, urgent, source);
+ let queued = session_control.queue_soft_interrupt(content, images, urgent, source);
let ack_queued = client_event_tx.send(ServerEvent::Ack { id }).is_ok();
crate::logging::info(&format!(
"SERVER_SOFT_INTERRUPT_QUEUE_RESULT id={} session={} queued={} ack_queued={}",
@@ -3145,6 +3582,20 @@ pub(super) async fn process_message_streaming_mpsc(
event_tx: tokio::sync::mpsc::UnboundedSender,
) -> Result<()> {
let mut agent = agent.lock().await;
+ process_locked_message_streaming_mpsc(&mut agent, content, images, system_reminder, event_tx)
+ .await
+}
+
+/// Same as [`process_message_streaming_mpsc`] for a caller that already holds
+/// the agent lock (e.g. a wake turn that reserved the idle agent up front, see
+/// #1152).
+pub(super) async fn process_locked_message_streaming_mpsc(
+ agent: &mut Agent,
+ content: &str,
+ images: Vec<(String, String)>,
+ system_reminder: Option,
+ event_tx: tokio::sync::mpsc::UnboundedSender,
+) -> Result<()> {
let session_id = agent.session_id().to_string();
let result = agent
.run_once_streaming_mpsc(content, images, system_reminder, event_tx)
@@ -3169,3 +3620,7 @@ pub(super) async fn process_message_streaming_mpsc(
#[cfg(test)]
#[path = "client_lifecycle_tests.rs"]
mod tests;
+
+#[cfg(test)]
+#[path = "client_target_attach_tests.rs"]
+mod target_attach_tests;
diff --git a/crates/jcode-app-core/src/server/client_lifecycle_catalog_dedup_tests.rs b/crates/jcode-app-core/src/server/client_lifecycle_catalog_dedup_tests.rs
new file mode 100644
index 0000000000..be8da0e342
--- /dev/null
+++ b/crates/jcode-app-core/src/server/client_lifecycle_catalog_dedup_tests.rs
@@ -0,0 +1,99 @@
+//! Tests for the `AvailableModelsUpdated` dedup key.
+//!
+//! Split out of `client_lifecycle_tests.rs` to keep that file under the
+//! test-size ratchet.
+
+use super::{available_models_dedup_key, strip_relative_age_text};
+use crate::protocol::ServerEvent;
+
+/// Relative cache ages ("12m ago") drift on their own. If they reach the
+/// catalog dedup key, every age rollover looks like a real catalog change and
+/// fans a full repaint out to every connected client, which starves the TUI
+/// input line.
+#[test]
+fn relative_age_text_is_normalized_out_of_the_catalog_dedup_key() {
+ assert_eq!(
+ strip_relative_age_text("openrouter · cached 12m ago · $3/M"),
+ "openrouter · cached · $3/M"
+ );
+ assert_eq!(
+ strip_relative_age_text("a 5s ago b 3h ago c 2d ago"),
+ "a b c "
+ );
+}
+
+#[test]
+fn catalog_dedup_key_ignores_age_drift_but_keeps_real_changes() {
+ let route = |detail: &str| crate::provider::ModelRoute {
+ model: "claude-opus-4.6".to_string(),
+ provider: "OpenRouter".to_string(),
+ api_method: "openrouter".to_string(),
+ available: true,
+ detail: detail.to_string(),
+ usage: None,
+ cheapness: None,
+ };
+ let event = |detail: &str| ServerEvent::AvailableModelsUpdated {
+ provider_name: Some("OpenRouter".to_string()),
+ provider_model: Some("claude-opus-4.6".to_string()),
+ available_models: vec!["claude-opus-4.6".to_string()],
+ available_model_routes: vec![route(detail)],
+ };
+
+ // Only the cosmetic age moved: same catalog, must dedup.
+ assert_eq!(
+ available_models_dedup_key(&event("cached 12m ago")),
+ available_models_dedup_key(&event("cached 41m ago")),
+ );
+ // A genuine detail change must still be visible.
+ assert_ne!(
+ available_models_dedup_key(&event("cached 12m ago")),
+ available_models_dedup_key(&event("rate limited, cached 12m ago")),
+ );
+}
+
+/// A trailing digit run with no unit/suffix must not index past the string.
+#[test]
+fn age_normalization_handles_trailing_digits_and_unicode() {
+ assert_eq!(strip_relative_age_text("tokens 12345"), "tokens 12345");
+ assert_eq!(strip_relative_age_text("9"), "9");
+ assert_eq!(strip_relative_age_text("9m"), "9m");
+ assert_eq!(
+ strip_relative_age_text("→ · émoji 7h ago"),
+ "→ · émoji "
+ );
+}
+
+/// Real route detail strings captured from a production OpenRouter catalog
+/// cache. These carry both self-ticking cache ages and endpoint stats
+/// (`p50`, `tps`) that only move when the endpoint cache genuinely refreshes.
+/// Ages must normalize away; stats must not, since a stats change means the
+/// upstream data really did change and clients should see it.
+#[test]
+fn production_route_details_normalize_ages_but_preserve_endpoint_stats() {
+ let with_age = "in $0.30/M, out $2.50/M, cache write $0.08/M, cache read $0.03/M, 100%, \
+ 493ms p50, 143tps, cache on, 17m ago";
+ let later_age = "in $0.30/M, out $2.50/M, cache write $0.08/M, cache read $0.03/M, 100%, \
+ 493ms p50, 143tps, cache on, 56m ago";
+ let changed_stats = "in $0.30/M, out $2.50/M, cache write $0.08/M, cache read $0.03/M, 100%, \
+ 900ms p50, 143tps, cache on, 17m ago";
+
+ // Age drift alone must collapse to the same key.
+ assert_eq!(
+ strip_relative_age_text(with_age),
+ strip_relative_age_text(later_age),
+ "cache-age drift must not look like a catalog change"
+ );
+ // A real endpoint-stat change must survive normalization.
+ assert_ne!(
+ strip_relative_age_text(with_age),
+ strip_relative_age_text(changed_stats),
+ "endpoint stat changes are real and must still propagate"
+ );
+ // `143tps` has no " ago" suffix, so it must be left intact.
+ assert!(
+ strip_relative_age_text(with_age).contains("143tps"),
+ "bare unit-suffixed numbers must not be mistaken for ages"
+ );
+ assert!(strip_relative_age_text(with_age).ends_with("cache on, "));
+}
diff --git a/crates/jcode-app-core/src/server/client_lifecycle_logging.rs b/crates/jcode-app-core/src/server/client_lifecycle_logging.rs
index 91e5934526..f1db919157 100644
--- a/crates/jcode-app-core/src/server/client_lifecycle_logging.rs
+++ b/crates/jcode-app-core/src/server/client_lifecycle_logging.rs
@@ -21,6 +21,7 @@ pub(super) fn interrupt_request_log_fields(
id,
content,
urgent,
+ ..
} => Some(format!(
"{} urgent={} content_bytes={} content_chars={}",
base("soft_interrupt", *id),
diff --git a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs
index 68aee1a276..59e6273ddb 100644
--- a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs
+++ b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs
@@ -1,5 +1,5 @@
use super::*;
-use crate::message::{Message, StreamEvent, ToolDefinition};
+use crate::message::{ContentBlock, Message, StreamEvent, ToolDefinition};
use crate::provider::{EventStream, Provider};
use async_trait::async_trait;
use futures::stream;
@@ -41,6 +41,7 @@ async fn session_control_handle_does_not_wait_for_busy_agent_lock() {
tokio::time::timeout(Duration::from_millis(100), async {
assert!(control.queue_soft_interrupt(
"please stop".to_string(),
+ Vec::new(),
true,
SoftInterruptSource::User,
));
@@ -192,6 +193,92 @@ async fn busy_agent_request_rejection_does_not_wait_for_agent_lock() {
assert!(client_event_rx.try_recv().is_err());
}
+#[tokio::test]
+async fn context_message_persists_without_starting_turn() {
+ let _guard = crate::storage::lock_test_env();
+ let _env = IsolatedReloadRecoveryEnv::new();
+ let session_id = "session_context_only_no_reply";
+ let forked = Arc::new(AtomicBool::new(false));
+ let provider: Arc = Arc::new(PanicOnForkProvider {
+ forked: Arc::clone(&forked),
+ });
+ let registry = Registry::new(Arc::clone(&provider)).await;
+ let mut session = crate::session::Session::create_with_id(session_id.to_string(), None, None);
+ session.model = Some("panic-on-fork".to_string());
+ let agent = Arc::new(Mutex::new(Agent::new_with_session(
+ provider, registry, session, None,
+ )));
+ let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::