chore: import upstream snapshot with attribution
CI / Quality Guardrails (push) Waiting to run
CI / Build & Test (macos-latest) (push) Waiting to run
CI / Build & Test (ubuntu-latest) (push) Waiting to run
CI / Build & Test (windows-latest) (push) Waiting to run
CI / Format (push) Waiting to run
CI / PowerShell Syntax (push) Waiting to run
CI / Windows Cross-Target Check (Linux) (push) Waiting to run
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[build]
# Set RUSTC_WRAPPER=sccache in your shell env; no hardcoded path needed.
# CI overrides this file, so leaving rustc-wrapper unset here is safe.
# Local fast-linker selection is handled by scripts/dev_cargo.sh so we don't
# hard-force a linker mode that may be broken on a contributor machine.
#
# This is a conservative *static fallback* for direct `cargo` invocations. The
# recommended build path (selfdev build -> scripts/dev_cargo.sh) ignores this
# and instead sizes the job count from currently-available memory, so several
# concurrent builds on one machine self-throttle instead of all assuming the
# full core count and tripping earlyoom/OOM. Override either path explicitly
# with CARGO_BUILD_JOBS / JCODE_BUILD_JOBS.
#
# The largest rustc unit (jcode-base) peaks at ~1.6 GiB RSS (was 2.5-3 GiB as a
# monolith), so 4 jobs (~6-7 GiB) keeps a single direct build comfortably
# memory-safe on a ~15 GiB machine while staying parallel. The adaptive
# dev_cargo.sh path uses more cores when memory allows.
jobs = 4
+1
View File
@@ -0,0 +1 @@
{"servers":{}}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Run a command with a hard timeout and readable diagnostics."""
from __future__ import annotations
import os
import signal
import subprocess
import sys
from typing import Sequence
def _usage() -> int:
print(
"usage: run_with_timeout.py <timeout-seconds> <command> [args...]",
file=sys.stderr,
)
return 2
def _kill_process_group(proc: subprocess.Popen[bytes]) -> None:
try:
pgid = os.getpgid(proc.pid)
except ProcessLookupError:
return
try:
os.killpg(pgid, signal.SIGTERM)
except ProcessLookupError:
return
def main(argv: Sequence[str]) -> int:
if len(argv) < 3:
return _usage()
try:
timeout_seconds = int(argv[1])
except ValueError:
print(f"invalid timeout: {argv[1]!r}", file=sys.stderr)
return 2
command = list(argv[2:])
print(f"==> Running with timeout {timeout_seconds}s: {' '.join(command)}")
proc = subprocess.Popen(command, start_new_session=True)
try:
return proc.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
print(
f"::error::Command exceeded timeout after {timeout_seconds}s: {' '.join(command)}",
file=sys.stderr,
)
_kill_process_group(proc)
try:
proc.wait(timeout=30)
return 124
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
return 124
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
@@ -0,0 +1,64 @@
param(
[Parameter(Mandatory = $true)][string]$ArtifactExePath,
[Parameter(Mandatory = $true)][string]$Version
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$resolvedArtifact = (Resolve-Path -LiteralPath $ArtifactExePath).Path
$tempRoot = Join-Path $env:RUNNER_TEMP ("jcode-windows-install-verify-" + [guid]::NewGuid().ToString('N'))
$localAppData = Join-Path $tempRoot 'localappdata'
$appData = Join-Path $tempRoot 'appdata'
$userProfile = Join-Path $tempRoot 'userprofile'
$jcodeHome = Join-Path $tempRoot '.jcode'
$installDir = Join-Path $localAppData 'jcode\bin'
New-Item -ItemType Directory -Force -Path $localAppData, $appData, $userProfile, $jcodeHome | Out-Null
$env:LOCALAPPDATA = $localAppData
$env:APPDATA = $appData
$env:USERPROFILE = $userProfile
$env:JCODE_HOME = $jcodeHome
$installScript = Join-Path $repoRoot 'scripts\install.ps1'
& $installScript `
-InstallDir $installDir `
-Version $Version `
-ArtifactExePath $resolvedArtifact `
-SkipAlacrittySetup `
-SkipHotkeySetup
$launcherPath = Join-Path $installDir 'jcode.exe'
$versionDir = Join-Path $localAppData ('jcode\builds\versions\' + $Version.TrimStart('v') + '\jcode.exe')
$stablePath = Join-Path $localAppData 'jcode\builds\stable\jcode.exe'
foreach ($path in @($launcherPath, $versionDir, $stablePath)) {
if (-not (Test-Path -LiteralPath $path)) {
throw "Expected installed file missing: $path"
}
}
$versionOutput = & $launcherPath --version
if ($LASTEXITCODE -ne 0) {
throw "Installed launcher failed to run --version"
}
if ($versionOutput -notmatch 'jcode') {
throw "Installed launcher returned unexpected version output: $versionOutput"
}
& $installScript `
-InstallDir $installDir `
-Version $Version `
-ArtifactExePath $resolvedArtifact `
-SkipAlacrittySetup `
-SkipHotkeySetup
if (-not (Test-Path -LiteralPath $launcherPath)) {
throw "Launcher missing after reinstall: $launcherPath"
}
Write-Host "Windows install verification passed for $Version" -ForegroundColor Green
+430
View File
@@ -0,0 +1,430 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
SCCACHE_GHA_ENABLED: "true"
jobs:
quality:
name: Quality Guardrails
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.DEPLOY_KEY }}
submodules: recursive
- name: Configure SSH for cargo git dependencies
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
key: quality-ubuntu
cache-all-crates: "true"
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check all targets and all features
run: cargo check --all-targets --all-features
- name: Run clippy with warnings denied
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Enforce warning budget
shell: bash
run: scripts/check_warning_budget.sh
- name: Enforce oversized-file ratchet
shell: bash
run: python3 scripts/check_code_size_budget.py
- name: Enforce oversized-test ratchet
shell: bash
run: python3 scripts/check_test_size_budget.py
- name: Enforce panic-prone usage ratchet
shell: bash
run: python3 scripts/check_panic_budget.py
- name: Enforce swallowed-error usage ratchet
shell: bash
run: python3 scripts/check_swallowed_error_budget.py
- name: Enforce crate dependency boundaries
shell: bash
run: python3 scripts/check_dependency_boundaries.py
- name: Enforce wildcard re-export ratchet
shell: bash
run: python3 scripts/check_wildcard_reexport_budget.py
- name: Enforce no unused dependencies
shell: bash
run: |
cargo install cargo-machete --locked
cargo machete
build:
name: Build & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 35
# 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,
# so the rustup proxy honours the file: convert_case pins `channel = "1.83.0"`
# and proc-macro2 requests `components = ["rust-src"]`. That made every
# Build & Test job (a) race two parallel `rust-src` downloads and (b) later
# try to compile convert_case with rustc 1.83.0, failing with E0514/E0599.
# RUSTUP_TOOLCHAIN takes precedence over any rust-toolchain.toml override, so
# pinning it here makes every step use this job's installed stable toolchain.
env:
RUSTUP_TOOLCHAIN: stable
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: macos-latest
target: aarch64-apple-darwin
steps:
- uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.DEPLOY_KEY }}
submodules: recursive
- name: Configure SSH for cargo git dependencies
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# Pre-install rust-src in the (serial) toolchain step. Some build-script
# / proc-macro units trigger an on-demand `rustup component add rust-src`,
# and when several parallel cargo/rustc processes request it at once they
# race on the shared `~/.rustup/downloads/*.partial` file and fail with
# "could not rename ... No such file or directory". Fetching it once here
# removes the race. (Was failing every Build & Test job.)
components: rust-src
- uses: Swatinem/rust-cache@v2
with:
# Suffix bumped to evict caches that held 1.83.0 / <unknown rustc>
# artifacts produced before RUSTUP_TOOLCHAIN=stable pinned the job.
key: ${{ matrix.os }}-stablepin
- name: Install mold linker (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq mold
- name: Build
shell: bash
run: |
mkdir -p .cargo
if [ "$RUNNER_OS" = "Linux" ]; then
cat > .cargo/config.toml << 'EOF'
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
EOF
fi
# NOTE: intentionally NOT exporting RUSTC_WRAPPER=sccache here. Only this
# step used sccache while the later `cargo test --no-run` steps did not,
# so sccache emitted rlibs/rmetas stamped `<unknown rustc version>` that
# the non-sccache test compile then rejected with E0514
# ("compiled by an incompatible version of rustc"). rust-cache already
# caches target/ across runs, so a clean, wrapper-consistent build is
# both correct and fast enough.
export RUSTC="$(rustup which rustc)"
CARGO_BIN="$(rustup which cargo)"
"$CARGO_BIN" build --release --target ${{ matrix.target }}
- name: Compile library and binary tests
shell: bash
run: |
python3 .github/scripts/run_with_timeout.py 900 \
"$(rustup which cargo)" test --target ${{ matrix.target }} --lib --bins --no-run
- name: Compile integration test binaries
shell: bash
# Build the integration-test binaries up front (not counted against the
# run steps' execution budgets). The e2e suite in particular is heavy
# (burst-spawn concurrency), and folding compilation into its 900s run
# budget was pushing slower hosted ubuntu/macos runners over the limit.
run: |
python3 .github/scripts/run_with_timeout.py 900 \
"$(rustup which cargo)" test --target ${{ matrix.target }} \
--test provider_matrix --test e2e --no-run
- name: Run provider matrix tests
shell: bash
run: |
python3 .github/scripts/run_with_timeout.py 600 \
"$(rustup which cargo)" test --target ${{ matrix.target }} --test provider_matrix
- name: Run e2e tests
shell: bash
run: |
# e2e is fast (~1-2 min of execution); the generous budget only guards
# against an occasional slow hosted runner. Compilation is already done
# in the dedicated compile step above.
python3 .github/scripts/run_with_timeout.py 600 \
"$(rustup which cargo)" test --target ${{ matrix.target }} --test e2e
- name: Check PowerShell script syntax (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
./scripts/check_powershell_syntax.ps1
- name: Enforce warning budget (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
scripts/check_warning_budget.sh
- name: Security preflight (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
cargo install cargo-audit --locked
scripts/security_preflight.sh --strict
windows-build-test:
name: Build & Test (windows-latest)
runs-on: windows-latest
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.DEPLOY_KEY }}
submodules: recursive
- name: Configure SSH for cargo git dependencies
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: amd64
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Setup sccache
uses: mozilla-actions/sccache-action@v0.0.7
continue-on-error: true
- uses: Swatinem/rust-cache@v2
with:
key: windows-latest
cache-all-crates: "true"
- name: Build release binary
shell: pwsh
run: |
if (Get-Command sccache -ErrorAction SilentlyContinue) {
sccache --start-server *> $null
if ($LASTEXITCODE -eq 0) {
$env:RUSTC_WRAPPER = 'sccache'
}
}
& cargo build --locked --release --target x86_64-pc-windows-msvc
if ($LASTEXITCODE -ne 0) {
throw 'Windows release build failed'
}
- name: Compile library and binary tests
shell: pwsh
run: |
& cargo test --locked --target x86_64-pc-windows-msvc --lib --bins --no-run
if ($LASTEXITCODE -ne 0) {
throw 'Windows library/binary test compilation failed'
}
- name: Run targeted Windows validation tests
shell: pwsh
run: |
$tests = @(
'command_candidates_adds_extension_on_windows',
'command_exists_for_known_binary',
'command_exists_absolute_path',
'sibling_socket_path_roundtrip',
'cleanup_socket_pair_removes_main_and_debug_files',
'is_process_running_reports_exited_children_as_stopped',
'spawn_replacement_process_returns_without_waiting_for_child_exit',
'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',
'stream_pair_round_trips_bytes',
'split_stream_supports_concurrent_read_and_write'
)
foreach ($testName in $tests) {
& ./scripts/invoke_cargo_with_timeout.ps1 `
-Name "Windows targeted test: $testName" `
-TimeoutSeconds 300 `
-CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--lib', $testName, '--', '--nocapture')
}
- name: Run Windows e2e smoke tests
shell: pwsh
run: |
$tests = @(
'provider_behavior::test_socket_model_cycle_supported_models',
'provider_behavior::test_model_switch_resets_provider_session'
)
foreach ($testName in $tests) {
& ./scripts/invoke_cargo_with_timeout.ps1 `
-Name "Windows e2e smoke test: $testName" `
-TimeoutSeconds 420 `
-CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--test', 'e2e', $testName, '--', '--exact', '--nocapture')
}
- name: Run Windows lifecycle e2e tests
shell: pwsh
env:
JCODE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/jcode-windows-e2e-logs
run: |
New-Item -ItemType Directory -Force -Path $env:JCODE_E2E_ARTIFACT_DIR | Out-Null
$tests = @(
'windows_lifecycle::windows_binary_server_accepts_clients_and_debug_cli',
'windows_lifecycle::windows_binary_server_rebinds_named_pipe_after_exit'
)
foreach ($testName in $tests) {
& ./scripts/invoke_cargo_with_timeout.ps1 `
-Name "Windows lifecycle e2e test: $testName" `
-TimeoutSeconds 420 `
-CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--test', 'e2e', $testName, '--', '--exact', '--nocapture')
}
- name: Upload Windows e2e diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: windows-e2e-diagnostics
path: ${{ runner.temp }}/jcode-windows-e2e-logs
if-no-files-found: ignore
- name: Verify built binary launches
shell: pwsh
run: |
& "target/x86_64-pc-windows-msvc/release/jcode.exe" --version
if ($LASTEXITCODE -ne 0) {
throw 'Built Windows binary failed to run --version'
}
- 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
fmt:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
powershell-syntax:
name: PowerShell Syntax
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Check PowerShell script syntax (Windows PowerShell 5.1)
shell: powershell
run: |
& ./scripts/check_powershell_syntax.ps1
- name: Check PowerShell script syntax (PowerShell 7)
shell: pwsh
run: |
& ./scripts/check_powershell_syntax.ps1
windows-cross-check:
name: Windows Cross-Target Check (Linux)
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.DEPLOY_KEY }}
submodules: recursive
- name: Configure SSH for cargo git dependencies
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc,aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
key: windows-cross-check
cache-all-crates: "true"
- name: Install LLVM toolchain for cargo-xwin
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq clang lld llvm ninja-build
- name: Install cargo-xwin
run: cargo install --git https://github.com/rust-cross/cargo-xwin cargo-xwin
- name: Check Windows x64 target
run: cargo xwin check --locked --target x86_64-pc-windows-msvc
# cargo-xwin currently feeds clang-style ring builds MSVC /imsvc flags for
# aarch64-pc-windows-msvc on Linux. Keep this advisory until upstream
# cargo-xwin/ring interop is fixed; native Windows ARM64 smoke covers the
# release artifact path.
- name: Check Windows ARM64 target (advisory)
continue-on-error: true
run: cargo xwin check --locked --target aarch64-pc-windows-msvc --no-default-features --features pdf
+88
View File
@@ -0,0 +1,88 @@
name: FreeBSD Smoke
# Builds and smoke-tests jcode inside a real FreeBSD VM.
#
# GitHub does not offer native FreeBSD runners, so we boot a FreeBSD guest
# (via QEMU) on a standard Ubuntu runner using vmactions/freebsd-vm. This runs
# the actual FreeBSD kernel and userland, giving a true build + run check
# instead of a cross-compile that can never link platform C dependencies
# (e.g. aws-lc-sys). See issue #416.
on:
workflow_dispatch:
inputs:
release:
description: Build in release mode (slower, matches shipping binary)
required: false
default: false
type: boolean
push:
paths:
- '.github/workflows/freebsd-smoke.yml'
schedule:
# Weekly drift check so FreeBSD breakage is caught even without a push.
- cron: '0 7 * * 1'
concurrency:
group: freebsd-smoke-${{ github.ref }}
cancel-in-progress: true
jobs:
smoke:
name: FreeBSD Smoke (x86_64)
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and test in FreeBSD VM
uses: vmactions/freebsd-vm@v1
with:
release: "15.1"
usesh: true
# Give the build VM enough room: aws-lc-sys + tract pull in heavy C/Rust.
mem: 6144
cpu: 4
# Install the toolchain jcode needs. The project is OpenSSL-free
# (rustls + aws-lc-rs), so we only need a C/C++ toolchain, cmake, and
# rust. pkgconf is handy for any transitive pkg-config probes.
prepare: |
pkg install -y rust cmake gmake pkgconf bash git
# CARGO_BUILD_JOBS keeps memory in check; aws-lc-sys is memory hungry.
run: |
set -e
echo "::group::Toolchain versions"
uname -a
cc --version | head -1
cargo --version
rustc --version
echo "::endgroup::"
export CARGO_TERM_COLOR=always
export CARGO_BUILD_JOBS=3
if [ "${{ inputs.release }}" = "true" ]; then
PROFILE_FLAG="--release"
TARGET_DIR="release"
else
PROFILE_FLAG=""
TARGET_DIR="debug"
fi
echo "::group::cargo build (jcode binary)"
cargo build --locked $PROFILE_FLAG -p jcode --bin jcode
echo "::endgroup::"
echo "::group::Verify binary launches"
./target/$TARGET_DIR/jcode --version
echo "::endgroup::"
echo "::group::Compile platform unit tests"
cargo test --locked $PROFILE_FLAG -p jcode-base --lib --no-run
echo "::endgroup::"
echo "::group::Run platform unit tests"
cargo test --locked $PROFILE_FLAG -p jcode-base --lib -- --nocapture platform
echo "::endgroup::"
+144
View File
@@ -0,0 +1,144 @@
name: iOS TestFlight
on:
workflow_dispatch:
push:
branches: [master]
paths:
- "ios/**"
- ".github/workflows/ios-testflight.yml"
concurrency:
group: ios-testflight-${{ github.ref }}
cancel-in-progress: true
env:
BUNDLE_ID: com.jcode.mobile
SCHEME: JCodeMobile
TEAM_ID: TAS6ARKDN7
jobs:
test:
name: swift test (JCodeKit)
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Swift toolchain info
run: |
sw_vers
xcodebuild -version
swift --version
- name: Run JCodeKit tests on macOS
working-directory: ios
run: swift test
compile-check:
name: xcodebuild (simulator, unsigned)
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate Xcode project
working-directory: ios
run: xcodegen generate
- name: Build app target for iOS simulator (no signing)
working-directory: ios
run: |
set -o pipefail
xcodebuild build \
-project JCodeMobile.xcodeproj \
-scheme "$SCHEME" \
-configuration Debug \
-destination "generic/platform=iOS Simulator" \
CODE_SIGNING_ALLOWED=NO \
| tail -60
build-and-upload:
name: Build, sign, upload to TestFlight
needs: [test, compile-check]
runs-on: macos-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate Xcode project
working-directory: ios
run: xcodegen generate
- name: Write App Store Connect API key
env:
APPSTORE_API_KEY_P8: ${{ secrets.APPSTORE_API_KEY_P8 }}
run: |
mkdir -p ~/private_keys
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)
working-directory: ios
run: |
set -o pipefail
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 \
DEVELOPMENT_TEAM="$TEAM_ID" \
CURRENT_PROJECT_VERSION="${{ github.run_number }}" \
| tail -100
- name: Write export options
working-directory: ios
run: |
cat > ExportOptions.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>upload</string>
<key>teamID</key>
<string>TAS6ARKDN7</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
EOF
- name: Export and upload to App Store Connect
working-directory: ios
run: |
set -o pipefail
xcodebuild -exportArchive \
-archivePath build/JCodeMobile.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build/export \
-allowProvisioningUpdates \
-authenticationKeyPath "$HOME/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8" \
-authenticationKeyID "${{ secrets.APPSTORE_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.APPSTORE_ISSUER_ID }}" \
| tail -60
- name: Cleanup API key
if: always()
run: rm -rf ~/private_keys
+575
View File
@@ -0,0 +1,575 @@
name: Release
on:
push:
tags:
- 'v*'
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
SCCACHE_GHA_ENABLED: "true"
CARGO_INCREMENTAL: "0"
jobs:
create-release:
name: Create release
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Render a human-readable changelog for the release body (issue #435):
# changelog/v<version>.json when present, otherwise grouped commit
# subjects since the previous tag, always ending with the compare link.
- name: Generate release notes
run: scripts/generate_release_notes.sh "${GITHUB_REF_NAME}" > release_notes.md
# Publish the release up front so each platform build can attach
# its own asset the moment it finishes, instead of everyone waiting for
# the slowest job. action-gh-release is idempotent: it creates the release
# for this tag if missing and updates it otherwise.
- name: Create release if missing
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
build-linux-macos:
name: Build (${{ matrix.target }})
runs-on: ${{ matrix.os }}
needs: create-release
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- # Build Linux x86_64 release assets on a CentOS 7 / manylinux2014
# glibc 2.17 baseline so they run on older distros as well as newer
# Debian/Ubuntu containers used by many TB tasks.
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
artifact: jcode-linux-x86_64
compat_container: true
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
artifact: jcode-linux-aarch64
- os: macos-latest
target: aarch64-apple-darwin
artifact: jcode-macos-aarch64
- os: macos-15-intel
target: x86_64-apple-darwin
artifact: jcode-macos-x86_64
steps:
- uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.DEPLOY_KEY }}
submodules: recursive
fetch-depth: 0
- name: Configure SSH for cargo git dependencies
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Setup sccache
uses: mozilla-actions/sccache-action@v0.0.7
continue-on-error: true
id: sccache
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
cache-all-crates: "true"
- name: Install mold linker (Linux)
if: runner.os == 'Linux' && matrix.compat_container != true
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq mold
- name: Build release binary
if: matrix.compat_container != true
shell: bash
run: |
mkdir -p .cargo
if [ "$RUNNER_OS" = "Linux" ]; then
cat > .cargo/config.toml << 'EOF'
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
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 }}
env:
JCODE_RELEASE_BUILD: "1"
JCODE_BUILD_SEMVER: ${{ github.ref_name }}
- name: Build portable Linux x86_64 release binary
if: matrix.compat_container == true
shell: bash
run: scripts/build_linux_compat.sh dist
env:
JCODE_RELEASE_BUILD: "1"
JCODE_BUILD_SEMVER: ${{ github.ref_name }}
JCODE_COMPAT_ARTIFACT: ${{ matrix.artifact }}
- name: Package binary
if: matrix.compat_container != true
run: |
mkdir -p dist
cp target/${{ matrix.target }}/release/jcode dist/${{ matrix.artifact }}
chmod +x dist/${{ matrix.artifact }}
cd dist && tar czf ${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: dist/${{ matrix.artifact }}.tar.gz
# Attach this platform's asset to the release immediately, so e.g. the
# macOS binary is downloadable ~15 min in without waiting for the slower
# Windows/Linux x86_64 jobs.
- name: Publish asset to release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" "dist/${{ matrix.artifact }}.tar.gz" --clobber
build-windows:
name: Build (${{ matrix.target }})
runs-on: ${{ matrix.os }}
needs: create-release
# Windows x64 release smoke tests compile the e2e harness after the release
# binary. GitHub-hosted Windows runners sometimes exceed 25 minutes, which
# cancels otherwise healthy releases before artifacts can be uploaded.
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: jcode-windows-x86_64
- os: windows-11-arm
target: aarch64-pc-windows-msvc
artifact: jcode-windows-aarch64
cargo_args: "--no-default-features --features pdf"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure MSVC build environment (x64)
if: matrix.target == 'x86_64-pc-windows-msvc'
uses: ilammy/msvc-dev-cmd@v1
with:
arch: amd64
- name: Configure MSVC build environment (ARM64)
if: matrix.target == 'aarch64-pc-windows-msvc'
uses: ilammy/msvc-dev-cmd@v1
with:
arch: amd64_arm64
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Setup sccache
uses: mozilla-actions/sccache-action@v0.0.7
continue-on-error: true
id: sccache
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
cache-all-crates: "true"
- name: Build release binary
shell: pwsh
run: |
if (Get-Command sccache -ErrorAction SilentlyContinue) {
sccache --start-server *> $null
if ($LASTEXITCODE -eq 0) {
$env:RUSTC_WRAPPER = "sccache"
}
}
$cargoArgs = @("build", "--release", "--target", "${{ matrix.target }}")
$extraArgs = "${{ matrix.cargo_args }}"
if (-not [string]::IsNullOrWhiteSpace($extraArgs)) {
$cargoArgs += $extraArgs -split ' '
}
& cargo @cargoArgs
env:
JCODE_RELEASE_BUILD: "1"
JCODE_BUILD_SEMVER: ${{ github.ref_name }}
- name: Run Windows runtime smoke tests (x64)
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: |
$tests = @(
'provider_behavior::test_socket_model_cycle_supported_models',
'provider_behavior::test_model_switch_resets_provider_session'
)
foreach ($testName in $tests) {
& cargo test --locked --target ${{ matrix.target }} --test e2e $testName -- --exact --nocapture
if ($LASTEXITCODE -ne 0) {
throw "Windows smoke test failed: $testName"
}
}
- name: Verify built Windows binary launches
shell: pwsh
run: |
& "target/${{ matrix.target }}/release/jcode.exe" --version
if ($LASTEXITCODE -ne 0) {
throw "Built Windows binary failed to run --version"
}
- name: Verify Windows installer with local artifact
shell: pwsh
run: |
& ./.github/scripts/verify_windows_install.ps1 `
-ArtifactExePath "target/${{ matrix.target }}/release/jcode.exe" `
-Version "${{ github.ref_name }}"
- name: Package binary
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item "target/${{ matrix.target }}/release/jcode.exe" "dist/${{ matrix.artifact }}.exe"
tar -czf "dist/${{ matrix.artifact }}.tar.gz" -C dist "${{ matrix.artifact }}.exe"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: |
dist/${{ matrix.artifact }}.tar.gz
dist/${{ matrix.artifact }}.exe
- name: Publish asset to release
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${env:GITHUB_REF_NAME}" "dist/${{ matrix.artifact }}.tar.gz" "dist/${{ matrix.artifact }}.exe" --clobber
build-freebsd:
name: Build (x86_64-unknown-freebsd)
runs-on: ubuntu-latest
needs: create-release
# GitHub has no native FreeBSD runners, so build inside a FreeBSD VM
# (QEMU via vmactions), same approach as freebsd-smoke.yml (issues #416,
# #433). Best-effort: a flaky VM build must not block the rest of the
# release, so failures are surfaced but non-fatal.
continue-on-error: true
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build release binary in FreeBSD VM
uses: vmactions/freebsd-vm@v1
with:
release: "15.1"
usesh: true
# Give the build VM enough room: aws-lc-sys + tract pull in heavy C/Rust.
mem: 6144
cpu: 4
# The project is OpenSSL-free (rustls + aws-lc-rs): only a C/C++
# toolchain, cmake, and rust are needed.
prepare: |
pkg install -y rust cmake gmake pkgconf bash git
run: |
set -e
echo "::group::Toolchain versions"
uname -a
cc --version | head -1
cargo --version
rustc --version
echo "::endgroup::"
export CARGO_TERM_COLOR=always
# 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_BUILD_SEMVER="${{ github.ref_name }}"
echo "::group::cargo build (jcode binary)"
cargo build --locked --release -p jcode --bin jcode
echo "::endgroup::"
echo "::group::Verify binary launches"
./target/release/jcode --version
echo "::endgroup::"
mkdir -p dist
cp target/release/jcode dist/jcode-freebsd-x86_64
chmod +x dist/jcode-freebsd-x86_64
cd dist && tar czf jcode-freebsd-x86_64.tar.gz jcode-freebsd-x86_64
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: jcode-freebsd-x86_64
path: dist/jcode-freebsd-x86_64.tar.gz
- name: Publish asset to release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" "dist/jcode-freebsd-x86_64.tar.gz" --clobber
release:
name: Finalize release
needs: [build-linux-macos, build-windows, build-freebsd]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/download-artifact@v4
with:
path: artifacts
pattern: jcode-*
- name: Generate checksums
shell: bash
run: |
python3 - << 'PY'
import hashlib
from pathlib import Path
files = sorted(
p for p in Path("artifacts").rglob("*")
if p.is_file() and (p.name.endswith(".tar.gz") or p.name.endswith(".exe"))
)
if not files:
raise SystemExit("No release assets found for checksum generation")
with Path("SHA256SUMS").open("w", encoding="utf-8") as out:
for path in files:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
out.write(f"{digest} {path.name}\n")
PY
cat SHA256SUMS
# Per-platform assets were already attached by their own build jobs as
# they finished; here we only add the cross-cutting checksums file.
- name: Upload checksums to release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" SHA256SUMS --clobber
- name: Update Homebrew formula
env:
HOMEBREW_DEPLOY_KEY: ${{ secrets.HOMEBREW_DEPLOY_KEY }}
if: env.HOMEBREW_DEPLOY_KEY != ''
run: |
VERSION="${GITHUB_REF_NAME}"
VERSION_NUM="${VERSION#v}"
LINUX_SHA=$(sha256sum artifacts/jcode-linux-x86_64/jcode-linux-x86_64.tar.gz | cut -d' ' -f1)
LINUX_ARM_SHA=$(sha256sum artifacts/jcode-linux-aarch64/jcode-linux-aarch64.tar.gz | cut -d' ' -f1)
MACOS_ARM_SHA=$(sha256sum artifacts/jcode-macos-aarch64/jcode-macos-aarch64.tar.gz | cut -d' ' -f1)
MACOS_INTEL_SHA=$(sha256sum artifacts/jcode-macos-x86_64/jcode-macos-x86_64.tar.gz | cut -d' ' -f1)
mkdir -p ~/.ssh
echo "$HOMEBREW_DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no"
git clone git@github.com:1jehuang/homebrew-jcode.git /tmp/homebrew-jcode
cat > /tmp/homebrew-jcode/Formula/jcode.rb << FORMULA
class Jcode < Formula
desc "AI coding agent powered by Claude and ChatGPT"
homepage "https://github.com/1jehuang/jcode"
version "${VERSION_NUM}"
license "MIT"
on_macos do
on_arm do
url "https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-macos-aarch64.tar.gz"
sha256 "${MACOS_ARM_SHA}"
def install
bin.install "jcode-macos-aarch64" => "jcode"
end
end
on_intel do
url "https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-macos-x86_64.tar.gz"
sha256 "${MACOS_INTEL_SHA}"
def install
bin.install "jcode-macos-x86_64" => "jcode"
end
end
end
on_linux do
on_intel do
url "https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-linux-x86_64.tar.gz"
sha256 "${LINUX_SHA}"
def install
libexec.install "jcode-linux-x86_64", "jcode-linux-x86_64.bin"
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" "$@"
SH
end
end
on_arm do
url "https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-linux-aarch64.tar.gz"
sha256 "${LINUX_ARM_SHA}"
def install
bin.install "jcode-linux-aarch64" => "jcode"
end
end
end
test do
assert_match "jcode", shell_output("#{bin}/jcode --version")
end
end
FORMULA
sed -i 's/^ //' /tmp/homebrew-jcode/Formula/jcode.rb
cd /tmp/homebrew-jcode
git config user.name "jcode-release-bot"
git config user.email "release@jcode.dev"
git add Formula/jcode.rb
git commit -m "Update to ${VERSION}" || echo "No changes"
git push
- name: Update AUR package
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
if: env.AUR_SSH_KEY != ''
run: |
set -euo pipefail
retry() {
local attempts="$1"
local delay="$2"
shift 2
local try=1
until "$@"; do
local exit_code=$?
if [ "$try" -ge "$attempts" ]; then
return "$exit_code"
fi
echo "Attempt ${try}/${attempts} failed; retrying in ${delay}s..."
sleep "$delay"
try=$((try + 1))
done
}
VERSION="${GITHUB_REF_NAME}"
VERSION_NUM="${VERSION#v}"
LINUX_SHA=$(sha256sum artifacts/jcode-linux-x86_64/jcode-linux-x86_64.tar.gz | cut -d' ' -f1)
LINUX_URL="https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-linux-x86_64.tar.gz"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$AUR_SSH_KEY" > ~/.ssh/aur_key
chmod 600 ~/.ssh/aur_key
touch ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
retry 3 5 bash -lc 'ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts'
export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -o ConnectTimeout=10 -o ConnectionAttempts=3"
retry 3 5 bash -lc 'rm -rf /tmp/jcode-aur && git clone --depth 1 ssh://aur@aur.archlinux.org/jcode-bin.git /tmp/jcode-aur'
cd /tmp/jcode-aur
git remote set-url origin ssh://aur@aur.archlinux.org/jcode-bin.git
cat > PKGBUILD << 'PKGBUILD_END'
# Maintainer: Jeremy Huang <jeremyhuang55555@gmail.com>
pkgname=jcode-bin
pkgver=VERSION_PLACEHOLDER
pkgrel=1
pkgdesc="AI coding agent powered by Claude and ChatGPT"
arch=('x86_64')
url="https://github.com/1jehuang/jcode"
license=('MIT')
provides=('jcode')
conflicts=('jcode')
source=("URL_PLACEHOLDER")
sha256sums=('SHA_PLACEHOLDER')
package() {
install -Dm755 "${srcdir}/jcode-linux-x86_64" "${pkgdir}/usr/lib/jcode/jcode-linux-x86_64"
install -Dm755 "${srcdir}/jcode-linux-x86_64.bin" "${pkgdir}/usr/lib/jcode/jcode-linux-x86_64.bin"
if compgen -G "${srcdir}/libssl.so*" >/dev/null; then
install -Dm644 "${srcdir}"/libssl.so* "${pkgdir}/usr/lib/jcode/"
fi
if compgen -G "${srcdir}/libcrypto.so*" >/dev/null; then
install -Dm644 "${srcdir}"/libcrypto.so* "${pkgdir}/usr/lib/jcode/"
fi
mkdir -p "${pkgdir}/usr/bin"
ln -s /usr/lib/jcode/jcode-linux-x86_64 "${pkgdir}/usr/bin/jcode"
}
PKGBUILD_END
sed -i "s|VERSION_PLACEHOLDER|${VERSION_NUM}|" PKGBUILD
sed -i "s|URL_PLACEHOLDER|${LINUX_URL}|" PKGBUILD
sed -i "s|SHA_PLACEHOLDER|${LINUX_SHA}|" PKGBUILD
sed -i 's/^ //' PKGBUILD
# Generate .SRCINFO without makepkg (AUR uses tab indentation)
printf 'pkgbase = jcode-bin\n' > .SRCINFO
printf '\tpkgdesc = AI coding agent powered by Claude and ChatGPT\n' >> .SRCINFO
printf '\tpkgver = %s\n' "${VERSION_NUM}" >> .SRCINFO
printf '\tpkgrel = 1\n' >> .SRCINFO
printf '\turl = https://github.com/1jehuang/jcode\n' >> .SRCINFO
printf '\tarch = x86_64\n' >> .SRCINFO
printf '\tlicense = MIT\n' >> .SRCINFO
printf '\tprovides = jcode\n' >> .SRCINFO
printf '\tconflicts = jcode\n' >> .SRCINFO
printf '\tsource = %s\n' "${LINUX_URL}" >> .SRCINFO
printf '\tsha256sums = %s\n' "${LINUX_SHA}" >> .SRCINFO
printf '\npkgname = jcode-bin\n' >> .SRCINFO
git config user.name "Jeremy Huang"
git config user.email "jeremyhuang55555@gmail.com"
git add PKGBUILD .SRCINFO
git commit -m "Update to ${VERSION}" || echo "No changes"
retry 3 5 git push origin master
+111
View File
@@ -0,0 +1,111 @@
name: Require Linked Issue
# 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
# * a closing keyword in the body/title (e.g. "Closes #123"), or
# * a plain mention (#123 / owner/repo#123) or full issue URL.
# Any referenced number is verified against the API: it must resolve to an
# existing issue (not a pull request) in this repository.
on:
pull_request:
types: [opened, edited, reopened, synchronize, ready_for_review]
permissions:
contents: read
pull-requests: read
issues: read
concurrency:
group: require-issue-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
require-issue:
name: Require Linked Issue
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check for a linked, existing issue
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
if (!pr) {
core.info('No pull_request payload; skipping.');
return;
}
const owner = context.repo.owner;
const repo = context.repo.repo;
const text = `${pr.title || ''}\n\n${pr.body || ''}`;
// Collect candidate issue numbers that belong to THIS repo.
const candidates = new Set();
// Full issue URLs: https://github.com/owner/repo/issues/123
const urlRe = /https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)/gi;
for (const m of text.matchAll(urlRe)) {
if (m[1].toLowerCase() === owner.toLowerCase() &&
m[2].toLowerCase() === repo.toLowerCase()) {
candidates.add(Number(m[3]));
}
}
// owner/repo#123 or bare #123 (bare assumed to be this repo).
const refRe = /(?:([\w.-]+)\/([\w.-]+))?#(\d+)/g;
for (const m of text.matchAll(refRe)) {
const refOwner = m[1];
const refRepo = m[2];
const num = Number(m[3]);
if (!refOwner && !refRepo) {
candidates.add(num); // bare #123 -> this repo
} else if (refOwner?.toLowerCase() === owner.toLowerCase() &&
refRepo?.toLowerCase() === repo.toLowerCase()) {
candidates.add(num);
}
}
// Issues linked via the Development sidebar (closing references).
try {
const query = `query($owner:String!, $repo:String!, $number:Int!) {
repository(owner:$owner, name:$repo) {
pullRequest(number:$number) {
closingIssuesReferences(first: 10) { nodes { number } }
}
}
}`;
const result = await github.graphql(query, { owner, repo, number: pr.number });
const nodes = result?.repository?.pullRequest?.closingIssuesReferences?.nodes || [];
for (const n of nodes) candidates.add(Number(n.number));
} catch (err) {
core.warning(`Could not query closing issue references: ${err}`);
}
// Verify at least one candidate is a real issue (not a PR, not this PR).
let linkedIssue = null;
for (const num of candidates) {
if (num === pr.number) continue;
try {
const { data } = await github.rest.issues.get({ owner, repo, issue_number: num });
if (!data.pull_request) { // issues have no pull_request field
linkedIssue = num;
break;
}
} catch (err) {
// 404 -> number doesn't exist; ignore and keep checking.
}
}
if (!linkedIssue) {
core.setFailed(
'This PR must be linked to an existing GitHub issue in this repository. ' +
'Add a closing keyword (e.g. "Closes #123") to the PR description, link an ' +
'issue via the Development sidebar, or reference an existing issue number ' +
'(e.g. "#123"). If no issue exists yet, please open one first.'
);
return;
}
core.info(`Linked to existing issue #${linkedIssue}. ✅`);
+215
View File
@@ -0,0 +1,215 @@
name: Windows Smoke
on:
workflow_dispatch:
inputs:
target:
description: Windows target to verify
required: true
default: x64
type: choice
options:
- x64
- arm64
- both
concurrency:
group: windows-smoke-${{ github.ref }}-${{ github.event.inputs.target || 'x64' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
SCCACHE_GHA_ENABLED: "true"
jobs:
smoke-x64:
name: Windows Smoke (x64)
if: github.event.inputs.target == 'x64' || github.event.inputs.target == 'both'
runs-on: windows-latest
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: amd64
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: mozilla-actions/sccache-action@v0.0.7
continue-on-error: true
- uses: Swatinem/rust-cache@v2
with:
key: windows-smoke-x64
cache-all-crates: "true"
- name: Build release binary
shell: pwsh
run: |
if (Get-Command sccache -ErrorAction SilentlyContinue) {
sccache --start-server *> $null
if ($LASTEXITCODE -eq 0) {
$env:RUSTC_WRAPPER = 'sccache'
}
}
& cargo build --locked --release --target x86_64-pc-windows-msvc
- name: Compile library and binary tests
shell: pwsh
run: |
& cargo test --locked --target x86_64-pc-windows-msvc --lib --bins --no-run
if ($LASTEXITCODE -ne 0) {
throw 'Windows library/binary test compilation failed'
}
- name: Run targeted Windows validation tests
shell: pwsh
run: |
$tests = @(
'command_candidates_adds_extension_on_windows',
'command_exists_for_known_binary',
'command_exists_absolute_path',
'sibling_socket_path_roundtrip',
'cleanup_socket_pair_removes_main_and_debug_files',
'is_process_running_reports_exited_children_as_stopped',
'spawn_replacement_process_returns_without_waiting_for_child_exit',
'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',
'stream_pair_round_trips_bytes',
'split_stream_supports_concurrent_read_and_write',
'auto_provider_noninteractive_skips_untrusted_external_auth_instead_of_blocking'
)
foreach ($testName in $tests) {
& ./scripts/invoke_cargo_with_timeout.ps1 `
-Name "Windows targeted test: $testName" `
-TimeoutSeconds 300 `
-CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--lib', $testName, '--', '--nocapture')
}
- name: Run Windows smoke tests
shell: pwsh
run: |
$tests = @(
'provider_behavior::test_socket_model_cycle_supported_models',
'provider_behavior::test_model_switch_resets_provider_session'
)
foreach ($testName in $tests) {
& ./scripts/invoke_cargo_with_timeout.ps1 `
-Name "Windows e2e smoke test: $testName" `
-TimeoutSeconds 420 `
-CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--test', 'e2e', $testName, '--', '--exact', '--nocapture')
}
- name: Run Windows lifecycle e2e tests
shell: pwsh
env:
JCODE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/jcode-windows-e2e-logs
run: |
New-Item -ItemType Directory -Force -Path $env:JCODE_E2E_ARTIFACT_DIR | Out-Null
$tests = @(
'windows_lifecycle::windows_binary_server_accepts_clients_and_debug_cli',
'windows_lifecycle::windows_binary_server_rebinds_named_pipe_after_exit'
)
foreach ($testName in $tests) {
& ./scripts/invoke_cargo_with_timeout.ps1 `
-Name "Windows lifecycle e2e test: $testName" `
-TimeoutSeconds 420 `
-CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--test', 'e2e', $testName, '--', '--exact', '--nocapture')
}
- name: Upload Windows e2e diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: windows-smoke-e2e-diagnostics-x64
path: ${{ runner.temp }}/jcode-windows-e2e-logs
if-no-files-found: ignore
- name: Verify built binary launches
shell: pwsh
run: |
& "target/x86_64-pc-windows-msvc/release/jcode.exe" --version
if ($LASTEXITCODE -ne 0) {
throw 'Built Windows binary failed to run --version'
}
- 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
smoke-arm64:
name: Windows Smoke (ARM64)
if: github.event.inputs.target == 'arm64' || github.event.inputs.target == 'both'
runs-on: windows-11-arm
timeout-minutes: 35
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: amd64_arm64
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-pc-windows-msvc
- uses: mozilla-actions/sccache-action@v0.0.7
continue-on-error: true
- uses: Swatinem/rust-cache@v2
with:
key: windows-smoke-arm64
cache-all-crates: "true"
- name: Build release binary
shell: pwsh
run: |
if (Get-Command sccache -ErrorAction SilentlyContinue) {
sccache --start-server *> $null
if ($LASTEXITCODE -eq 0) {
$env:RUSTC_WRAPPER = 'sccache'
}
}
& cargo build --locked --release --target aarch64-pc-windows-msvc --no-default-features --features pdf
- name: Verify built binary launches
shell: pwsh
run: |
& "target/aarch64-pc-windows-msvc/release/jcode.exe" --version
if ($LASTEXITCODE -ne 0) {
throw 'Built Windows ARM64 binary failed to run --version'
}
- 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
+20
View File
@@ -0,0 +1,20 @@
/target
Cargo.lock
__pycache__/
ios_simulator_screenshot.png
/.wrangler/
/tmp/
# Local onboarding sandbox + auth fixtures. May contain REAL OAuth tokens copied
# from $HOME (see scripts/onboarding_sandbox.sh seed-real-logins). Never commit.
/.tmp/
/.jcode/generated-images/
/telemetry-worker/backups/
target-ttest/
target-ttest*/
# Stray experiment/debug artifacts at the repo root. Real assets live under
# assets/, docs/, ios/, and tests/ and are committed explicitly.
/*.log
/*.avif
/*.mp4
/*.png
+84
View File
@@ -0,0 +1,84 @@
---
name: optimization
description: Use when improving performance, latency, throughput, memory usage, or general efficiency. Start by defining target metrics, measuring comprehensively, attributing bottlenecks, validating with static analysis, and prioritizing macro-optimizations before micro-optimizations.
allowed-tools: bash, read, write, grep, agentgrep, batch, todo
---
# Optimization
Use this skill when the task is about making a system faster, lighter, more scalable, or otherwise more efficient.
## Core principle
To optimize properly, you must know:
1. **What metrics you are chasing**
2. **What your real bottlenecks are**
Do not optimize blindly.
## 1. Define the target metrics first
Before changing code, make sure you have the right measurements.
- Identify the exact metrics that matter: latency, throughput, memory, CPU, startup time, compile time, query count, token usage, cost, etc.
- Measure **comprehensively**, not just a convenient subset.
- Make sure the metrics are accurate and representative of the real workload.
- Prefer measurements that are fast to run so you can iterate quickly.
- If possible, create repeatable benchmarks or scripts so improvements are verifiable.
## 2. Get full bottleneck attribution
You should have strong attribution for what each part of the system is doing.
- Instrument the system so you can see where time and resources are going.
- Prefer both:
- **Ad hoc inspection** for quick debugging
- **Logged measurements** for later analysis and comparison
- Attribute work across the full path, not just the obviously slow component.
- Make sure the data is detailed enough to explain where the cost comes from.
If you can analyze runs after the fact with logs or traces, that is often much more powerful than relying only on live inspection.
## 3. Use static analysis too
Not every optimization problem needs runtime profiling first. Often, code inspection reveals the issue.
Check for:
- Wrong asymptotic complexity
- The wrong algorithm or data structure
- Unnecessary repeated work
- Work happening in the wrong layer
- Inefficient architecture or control flow
- Directionally incorrect approaches
Make sure your asymptotics are right and the overall algorithm makes sense before tuning small details.
## 4. Macro-optimize before micro-optimizing
Prioritize the largest wins first.
- Remove whole classes of work before making existing work slightly cheaper.
- Fix architecture, batching, caching, query patterns, algorithm choice, parallelism, and data movement before focusing on tiny low-level tweaks.
- If you are very far from the expected metrics, spend more time on macro-optimization.
Micro-optimizations matter most after the major inefficiencies are already addressed.
## Recommended workflow
1. Define success metrics.
2. Reproduce the current baseline.
3. Add measurement and attribution if missing.
4. Identify the top bottleneck.
5. Check for algorithmic or architectural issues.
6. Apply the highest-leverage fix first.
7. Re-measure.
8. Repeat until the target is met or tradeoffs stop being worth it.
## Guardrails
- Do not claim an optimization without before/after evidence.
- Be careful not to optimize the wrong metric.
- Watch for regressions in correctness, reliability, maintainability, and security.
- Prefer changes that are measurable, explainable, and reversible.
+27
View File
@@ -0,0 +1,27 @@
# Repository Guidelines
## 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
## Install Notes
- `~/.local/bin/jcode` is the launcher symlink used from `PATH`.
- `~/.jcode/builds/current/jcode` is the active local/source-build channel; self-dev builds and `scripts/install_release.sh` point the launcher here.
- `~/.jcode/builds/stable/jcode` is the stable release channel; `scripts/install.sh` installs this and points the launcher here.
- `~/.jcode/builds/versions/<version>/jcode` stores immutable binaries.
- `~/.jcode/builds/canary/jcode` still exists for canary/testing flows, but it is not the primary self-dev install path.
- 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\\<version>\\jcode.exe` for immutable installs; `scripts/install.ps1` currently installs the stable channel.
- Ensure `~/.local/bin` is **before** `~/.cargo/bin` in `PATH`.
+31
View File
@@ -0,0 +1,31 @@
# Contributing to jcode
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.
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.
## Pull request policy
Pull requests are welcome and encouraged.
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.
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.
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.
The best PRs therefore include:
- a clear description of the problem being solved
- a minimal reproduction or failing test when possible
- notes about edge cases and tradeoffs
- 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.
+491
View File
@@ -0,0 +1,491 @@
[package]
name = "jcode"
version = "0.46.0"
description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools"
edition = "2024"
autobins = false
[workspace]
members = [
".",
"crates/jcode-agent-runtime",
"crates/jcode-app-core",
"crates/jcode-base",
"crates/jcode-tui",
"crates/jcode-build-meta",
"crates/jcode-ambient-types",
"crates/jcode-auth-types",
"crates/jcode-embedding",
"crates/jcode-gateway-types",
"crates/jcode-import-core",
"crates/jcode-pdf",
"crates/jcode-logging",
"crates/jcode-background-types",
"crates/jcode-batch-types",
"crates/jcode-build-support",
"crates/jcode-compaction-core",
"crates/jcode-config-types",
"crates/jcode-core",
"crates/jcode-fuzzy",
"crates/jcode-memory-types",
"crates/jcode-message-types",
"crates/jcode-overnight-core",
"crates/jcode-plan",
"crates/jcode-productivity-core",
"crates/jcode-swarm-core",
"crates/jcode-protocol",
"crates/jcode-selfdev-types",
"crates/jcode-session-types",
"crates/jcode-setup-hints",
"crates/jcode-storage",
"crates/jcode-side-panel-types",
"crates/jcode-azure-auth",
"crates/jcode-notify-email",
"crates/jcode-provider-metadata",
"crates/jcode-provider-env",
"crates/jcode-provider-core",
"crates/jcode-provider-bedrock",
"crates/jcode-provider-anthropic",
"crates/jcode-provider-antigravity",
"crates/jcode-provider-copilot",
"crates/jcode-provider-openrouter",
"crates/jcode-provider-openai",
"crates/jcode-provider-gemini",
"crates/jcode-provider-gemini-runtime",
"crates/jcode-provider-cursor-runtime",
"crates/jcode-provider-antigravity-runtime",
"crates/jcode-provider-copilot-runtime",
"crates/jcode-provider-claude-cli-runtime",
"crates/jcode-provider-openrouter-runtime",
"crates/jcode-provider-anthropic-runtime",
"crates/jcode-provider-openai-runtime",
"crates/jcode-provider-doctor",
"crates/jcode-tui-markdown",
"crates/jcode-tui-messages",
"crates/jcode-usage-types",
"crates/jcode-tui-core",
"crates/jcode-tui-mermaid",
"crates/jcode-task-types",
"crates/jcode-tool-core",
"crates/jcode-tool-types",
"crates/jcode-tui-account-picker",
"crates/jcode-tui-anim",
"crates/jcode-tui-render",
"crates/jcode-tui-visual-debug",
"crates/jcode-tui-session-picker",
"crates/jcode-tui-style",
"crates/jcode-tui-permissions",
"crates/jcode-tui-tool-display",
"crates/jcode-tui-usage-overlay",
"crates/jcode-update-core",
"crates/jcode-terminal-launch",
"crates/jcode-terminal-image",
"crates/jcode-telemetry-core",
"crates/jcode-tui-workspace",
"crates/jcode-desktop",
"crates/jcode-render-core",
]
[lib]
name = "jcode"
path = "src/lib.rs"
[[bin]]
name = "jcode"
path = "src/main.rs"
[[bin]]
name = "test_api"
path = "src/bin/test_api.rs"
[[bin]]
name = "jcode-harness"
path = "src/bin/harness.rs"
[[bin]]
name = "session_memory_bench"
path = "src/bin/session_memory_bench.rs"
required-features = ["dev-bins"]
[[bin]]
name = "memory_recall_bench"
path = "src/bin/memory_recall_bench.rs"
required-features = ["dev-bins"]
[[bin]]
name = "mermaid_side_panel_probe"
path = "src/bin/mermaid_side_panel_probe.rs"
required-features = ["dev-bins"]
[[bin]]
name = "tui_bench"
path = "src/bin/tui_bench.rs"
required-features = ["dev-bins"]
[dependencies]
# Memory allocator (reduces fragmentation for long-running server)
tikv-jemallocator = { version = "0.6", features = ["unprefixed_malloc_on_supported_platforms"], optional = true }
# Async runtime
tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
futures = "0.3"
async-trait = "0.1"
# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
toml = "0.8"
# CLI
clap = { version = "4", features = ["derive"] }
# File operations
# Utilities
dirs = "5" # home directory
anyhow = "1"
libc = "0.2" # Unix system calls (flock)
chrono = { version = "0.4", features = ["serde"] }
# Embeddings (local inference) - behind feature flag (163 crates, slow to compile)
# OAuth
sha2 = "0.10"
hex = "0.4"
open = "5" # Open URLs in browser
jcode-tui-session-picker = { path = "crates/jcode-tui-session-picker", features = ["serde"] }
# Streaming
tokio-stream = "0.1"
# TUI
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
# Markdown & syntax highlighting
# PDF parsing (behind feature flag)
jcode-build-meta = { path = "crates/jcode-build-meta" }
# 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 +
# bin) re-exports everything via `pub use jcode_tui::*`. default-features=false
# so the root feature set fully controls the downstream features (see
# [features]).
jcode-tui = { path = "crates/jcode-tui", default-features = false }
# `jcode provider-doctor` / provider diagnostics; sits downstream of jcode-base
# so doctor edits do not rebuild the base -> app-core -> tui spine.
jcode-provider-doctor = { path = "crates/jcode-provider-doctor" }
# Gemini provider runtime; sits downstream of jcode-base so gemini edits do
# not rebuild the base -> app-core -> tui spine. Registered with the external
# provider registry at startup (src/cli/startup.rs).
jcode-provider-gemini-runtime = { path = "crates/jcode-provider-gemini-runtime" }
jcode-provider-cursor-runtime = { path = "crates/jcode-provider-cursor-runtime" }
jcode-provider-antigravity-runtime = { path = "crates/jcode-provider-antigravity-runtime" }
jcode-provider-copilot-runtime = { path = "crates/jcode-provider-copilot-runtime" }
jcode-provider-claude-cli-runtime = { path = "crates/jcode-provider-claude-cli-runtime" }
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-selfdev-types = { path = "crates/jcode-selfdev-types" }
# Archive extraction (for auto-update)
tempfile = "3"
[features]
# Include local ONNX/tokenizer embeddings in default builds so memory recall,
# semantic retrieval, and embedding-backed features work out of the box.
# Use `JCODE_DEV_FEATURE_PROFILE=minimal` for compile-speed probes that need
# to skip optional default feature stacks.
default = ["pdf", "embeddings", "bedrock"]
dev-bins = ["jcode-tui/dev-bins"]
jemalloc = [
"dep:tikv-jemallocator",
"tikv-jemallocator/stats",
"jcode-tui/jemalloc",
]
jemalloc-prof = [
"jemalloc",
"tikv-jemallocator/profiling",
"jcode-tui/jemalloc-prof",
]
# Local embeddings and PDF extraction live in jcode-base / jcode-app-core;
# these features just forward down the stack.
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"]
mmdr-size-api = ["jcode-tui/mmdr-size-api"]
pdf = ["jcode-tui/pdf"]
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
[target.'cfg(target_os = "macos")'.dependencies]
global-hotkey = "0.7"
objc2 = "0.6"
block2 = "0.6"
objc2-foundation = { version = "0.3", features = [
"NSString",
"NSThread",
"NSRunLoop",
"NSDate",
"NSTimer",
"NSUserDefaults",
"NSValue",
"NSAttributedString",
"NSDictionary",
"block2",
] }
objc2-app-kit = { version = "0.3", features = [
"NSAppearance",
"NSApplication",
"NSResponder",
"NSStatusBar",
"NSStatusItem",
"NSStatusBarButton",
"NSButton",
"NSCell",
"NSColor",
"NSControl",
"NSFont",
"NSFontDescriptor",
"NSImage",
"NSAttributedString",
"NSMenu",
"NSMenuItem",
"NSRunningApplication",
] }
[profile.release]
opt-level = 1
debug = 0
codegen-units = 256
incremental = true
[profile.selfdev]
inherits = "release"
opt-level = 0
# Full LTO release for stable/distribution builds
[profile.release-lto]
inherits = "release"
lto = "thin"
codegen-units = 16
incremental = false
[profile.dev]
debug = 0
incremental = true
[profile.dev.package."*"]
opt-level = 0
# Keep the idle-animation math kernels fully optimized even in debug-style
# builds. This crate is pure, dependency-free numeric code (the trig-heavy 3D
# samplers that otherwise dominate idle CPU). Pinning it to opt-level = 3 here
# means the optimized object is compiled once and reused across iterative
# dev/selfdev rebuilds, since it only recompiles when the animation code itself
# changes. The "*" glob above sets the default for dependencies; these explicit
# package entries take precedence for this one crate in each profile.
[profile.dev.package."jcode-tui-anim"]
opt-level = 3
[profile.selfdev.package."jcode-tui-anim"]
opt-level = 3
[profile.test.package."jcode-tui-anim"]
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
# transcript glyph shaping. At opt-level = 0 they are 15-40x slower, which made
# scrolling real (emoji/markdown-heavy) transcripts janky in dev/selfdev builds
# even though the production release build was smooth. These are stable
# third-party crates that almost never recompile, so pinning them to opt-level =
# 3 costs a one-time compile and is then reused across every iterative jcode
# rebuild (same rationale as jcode-tui-anim above). It does NOT slow down
# recompiles of jcode's own crates.
[profile.dev.package.cosmic-text]
opt-level = 3
[profile.selfdev.package.cosmic-text]
opt-level = 3
[profile.test.package.cosmic-text]
opt-level = 3
[profile.dev.package.rustybuzz]
opt-level = 3
[profile.selfdev.package.rustybuzz]
opt-level = 3
[profile.test.package.rustybuzz]
opt-level = 3
[profile.dev.package.ttf-parser]
opt-level = 3
[profile.selfdev.package.ttf-parser]
opt-level = 3
[profile.test.package.ttf-parser]
opt-level = 3
[profile.dev.package.swash]
opt-level = 3
[profile.selfdev.package.swash]
opt-level = 3
[profile.test.package.swash]
opt-level = 3
[profile.dev.package.yazi]
opt-level = 3
[profile.selfdev.package.yazi]
opt-level = 3
[profile.test.package.yazi]
opt-level = 3
[profile.dev.package.fontdb]
opt-level = 3
[profile.selfdev.package.fontdb]
opt-level = 3
[profile.test.package.fontdb]
opt-level = 3
# Keep the raster image pipeline optimized in dev/selfdev/test builds.
#
# Inline transcript images decode (png/zune-jpeg/fdeflate/miniz_oxide), resize
# (image), and base64-encode (kitty transmit) at draw time; at opt-level 0 a
# single screenshot costs 100-200ms per decode/resize, which reads as input
# lag. These are pure pixel crates with no jcode logic, so optimizing them
# does not hurt rebuild times for our own code.
[profile.dev.package.image]
opt-level = 3
[profile.selfdev.package.image]
opt-level = 3
[profile.test.package.image]
opt-level = 3
[profile.dev.package.png]
opt-level = 3
[profile.selfdev.package.png]
opt-level = 3
[profile.test.package.png]
opt-level = 3
[profile.dev.package.zune-jpeg]
opt-level = 3
[profile.selfdev.package.zune-jpeg]
opt-level = 3
[profile.test.package.zune-jpeg]
opt-level = 3
[profile.dev.package.fdeflate]
opt-level = 3
[profile.selfdev.package.fdeflate]
opt-level = 3
[profile.test.package.fdeflate]
opt-level = 3
[profile.dev.package.miniz_oxide]
opt-level = 3
[profile.selfdev.package.miniz_oxide]
opt-level = 3
[profile.test.package.miniz_oxide]
opt-level = 3
[profile.dev.package.base64]
opt-level = 3
[profile.selfdev.package.base64]
opt-level = 3
[profile.test.package.base64]
opt-level = 3
# session_search scans multi-megabyte session files with memchr-based
# case-insensitive matching; keep the SIMD scanner optimized everywhere.
[profile.dev.package.memchr]
opt-level = 3
[profile.selfdev.package.memchr]
opt-level = 3
[profile.test.package.memchr]
opt-level = 3
# The embedding stack (tract ONNX inference + HF tokenizer) runs the MiniLM
# model for memory recall/maintenance on the shared server. At opt-level 0 a
# single embed measured ~666 ms (matmul inner loops unoptimized), which keeps
# the model "busy" long enough that the idle unloader rarely fires and burns
# CPU on every memory-maintenance pass. These crates are pure compute and
# change only when the dependency is bumped, so the optimized objects are
# compiled once and cached across dev/selfdev iterations.
[profile.dev.package.tract-core]
opt-level = 3
[profile.selfdev.package.tract-core]
opt-level = 3
[profile.test.package.tract-core]
opt-level = 3
[profile.dev.package.tract-linalg]
opt-level = 3
[profile.selfdev.package.tract-linalg]
opt-level = 3
[profile.test.package.tract-linalg]
opt-level = 3
[profile.dev.package.tract-data]
opt-level = 3
[profile.selfdev.package.tract-data]
opt-level = 3
[profile.test.package.tract-data]
opt-level = 3
[profile.dev.package.tract-hir]
opt-level = 3
[profile.selfdev.package.tract-hir]
opt-level = 3
[profile.test.package.tract-hir]
opt-level = 3
[profile.dev.package.tract-onnx]
opt-level = 3
[profile.selfdev.package.tract-onnx]
opt-level = 3
[profile.test.package.tract-onnx]
opt-level = 3
[profile.dev.package.tract-onnx-opl]
opt-level = 3
[profile.selfdev.package.tract-onnx-opl]
opt-level = 3
[profile.test.package.tract-onnx-opl]
opt-level = 3
[profile.dev.package.tract-nnef]
opt-level = 3
[profile.selfdev.package.tract-nnef]
opt-level = 3
[profile.test.package.tract-nnef]
opt-level = 3
[profile.dev.package.ndarray]
opt-level = 3
[profile.selfdev.package.ndarray]
opt-level = 3
[profile.test.package.ndarray]
opt-level = 3
[profile.dev.package.tokenizers]
opt-level = 3
[profile.selfdev.package.tokenizers]
opt-level = 3
[profile.test.package.tokenizers]
opt-level = 3
[profile.test]
debug = 0
incremental = true
codegen-units = 256
[dev-dependencies]
async-stream = "0.3"
# 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
# target. Feature unification applies this only to test/bench/example builds,
# never to a normal `cargo build`/release binary.
jcode-tui = { path = "crates/jcode-tui", features = ["test-support"] }
[build-dependencies]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Jeremy Huang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+371
View File
@@ -0,0 +1,371 @@
# Auth Notes: OAuth + API-key Providers
This document explains how authentication works in J-Code.
## Overview
J-Code can detect existing local credentials and can also run built-in OAuth and API-key login flows.
For auth files managed by other tools/CLIs, jcode asks before reading them. If you
approve a source, jcode remembers that approval for that external auth file path
for future sessions and still leaves the original file untouched (no move,
rewrite, or permission mutation). Symlinked external auth files are rejected.
Credentials are stored locally:
- J-Code Claude OAuth (if logged in via `jcode login --provider claude`): `~/.jcode/auth.json`
- Claude Code CLI: `~/.claude/.credentials.json` (Linux/Windows), or the **macOS login Keychain** item `Claude Code-credentials` (the default on macOS, where the JSON file usually does not exist), or the `CLAUDE_CODE_OAUTH_TOKEN` env var (set by `claude setup-token`)
- OpenCode (optional provider/OAuth import source): `~/.local/share/opencode/auth.json`
- pi (optional provider/OAuth import source): `~/.pi/agent/auth.json`
- J-Code OpenAI/Codex OAuth: `~/.jcode/openai-auth.json`
- Codex CLI auth source (read in place only after confirmation): `~/.codex/auth.json`
- Gemini native OAuth: `~/.jcode/gemini_oauth.json`
- Gemini CLI import fallback: `~/.gemini/oauth_creds.json`
- Copilot CLI plaintext fallback: `~/.copilot/config.json`
- Legacy Copilot JSON sources: `~/.config/github-copilot/hosts.json`, `~/.config/github-copilot/apps.json`
Relevant code:
- Claude provider: `src/provider/claude.rs`
- OpenAI login + refresh: `src/auth/oauth.rs`
- OpenAI credentials parsing: `src/auth/codex.rs`
- OpenAI requests: `src/provider/openai.rs`
- Azure OpenAI auth/config: `src/auth/azure.rs`
- Azure OpenAI transport: `src/provider/openrouter.rs`
- Gemini login + refresh: `src/auth/gemini.rs`
- Gemini Code Assist provider: `src/provider/gemini.rs`
- OpenAI-compatible provider metadata/login descriptors: `crates/jcode-provider-metadata/src/lib.rs`
## Claude (Claude Max)
### Login steps
1. Run `jcode login --provider claude` (recommended), or `jcode login` and choose Claude.
- For headless / SSH use: `jcode login --provider claude --no-browser`
- For scriptable remote flows: `jcode login --provider claude --print-auth-url`, then later complete with `--callback-url` or `--auth-code`
2. Alternative: run `claude` (or `claude setup-token`). jcode can detect Claude Code's credentials, ask before reading them, and remember that approval for future sessions. This works whether Claude Code stored them in `~/.claude/.credentials.json` (Linux/Windows), the macOS login Keychain (`Claude Code-credentials`), or the `CLAUDE_CODE_OAUTH_TOKEN` env var. On macOS, approving the Keychain source copies the credentials into `~/.jcode/auth.json` once so later sessions never re-prompt the Keychain.
3. Verify with `jcode --provider claude run "Say hello from jcode"`.
Credential discovery order is:
1. `~/.jcode/auth.json`
2. `~/.claude/.credentials.json`
3. Claude Code native credentials (macOS Keychain `Claude Code-credentials`, or `CLAUDE_CODE_OAUTH_TOKEN` env var) once approved
4. `~/.local/share/opencode/auth.json`
5. `~/.pi/agent/auth.json`
### Direct Anthropic API (default)
`--provider claude` uses the direct Anthropic Messages API by default.
jcode owns the full runtime path itself: auth, refresh, request shaping, tool
compatibility, and transport.
#### Claude OAuth direct API compatibility
Claude Code OAuth tokens can be used directly against the Messages API, but only
if the request matches the Claude Code "OAuth contract". jcode applies this
automatically for the default Claude runtime path.
Required behaviors (applied by the Anthropic provider):
- Use the Messages endpoint with `?beta=true`.
- Send `User-Agent: claude-cli/1.0.0`.
- Send `anthropic-beta: oauth-2025-04-20,claude-code-20250219`.
- Prepend the system blocks with the Claude Code identity line as the first
block:
- `You are Claude Code, Anthropic's official CLI for Claude.`
Tool name allow-list:
Claude OAuth requests reject certain tool names. jcode remaps a small set of
builtin tool names on the wire to the Claude-Code builtin names and maps them
back on responses so native tools continue to work. Every other tool is
forwarded under its own name, so the full custom toolset (websearch, webfetch,
browser, codesearch, memory, swarm, multiedit, open, ...) stays available on
OAuth. The remapped names are:
- `bash``Bash`
- `read``Read`
- `write``Write`
- `edit``Edit`
- `glob``Glob`
- `grep``Grep`
- `subagent``Agent`
- `schedule``ScheduleWakeup`
- `skill_manage``Skill`
Notes:
- If the OAuth token expires, refresh via the Claude OAuth refresh endpoint.
- Without the identity line and allow-listed tool names, the API will reject
OAuth requests even if the token is otherwise valid.
### Deprecated Claude CLI transport
The old Claude CLI shell-out path is deprecated and should only be used for
legacy compatibility.
You can still force it temporarily with:
- `JCODE_USE_CLAUDE_CLI=1`
- or `--provider claude-subprocess` (deprecated hidden compatibility value)
These environment variables control the deprecated Claude Code CLI transport:
- `JCODE_CLAUDE_CLI_PATH` (default: `claude`)
- `JCODE_CLAUDE_CLI_MODEL` (default: `claude-opus-4-5-20251101`)
- `JCODE_CLAUDE_CLI_PERMISSION_MODE` (default: `bypassPermissions`)
- `JCODE_CLAUDE_CLI_PARTIAL` (set to `0` to disable partial streaming)
## OpenAI / Codex OAuth
### Login steps
1. Run `jcode login --provider openai`.
- For headless / SSH use: `jcode login --provider openai --no-browser`
- For scriptable remote flows: `jcode login --provider openai --print-auth-url`, then later complete with `--callback-url`
2. Your browser opens to the OpenAI OAuth page unless you use `--no-browser`. The local callback listens on
`http://localhost:1455/auth/callback` by default.
If port `1455` is unavailable, jcode falls back to a manual paste flow where
you can paste the full callback URL or query string.
3. After login, tokens are saved to `~/.jcode/openai-auth.json`.
Credential discovery order is:
1. `~/.jcode/openai-auth.json`
2. `~/.codex/auth.json`
3. trusted OpenCode/pi OAuth in `~/.local/share/opencode/auth.json` / `~/.pi/agent/auth.json`
4. `OPENAI_API_KEY`
If jcode finds existing credentials in `~/.codex/auth.json`, it asks before
reading them. When approved, it remembers that trust decision for future jcode
sessions and still does not move, delete, or rewrite the Codex file.
### Request details
J-Code uses the Responses API. If you have a ChatGPT subscription (refresh
token or id_token present), requests go to:
- `https://chatgpt.com/backend-api/codex/responses`
with headers:
- `originator: codex_cli_rs`
- `chatgpt-account-id: <from token>`
Otherwise it uses:
- `https://api.openai.com/v1/responses`
For **API-key** usage (no ChatGPT/Codex OAuth), the Responses API base URL is
overridable so you can target a local or proxied Responses-API endpoint. Set one
of (checked in this order) to an absolute `http(s)://` base that ends in the API
version, e.g. `http://127.0.0.1:8317/v1`:
- `JCODE_OPENAI_API_BASE`
- `OPENAI_BASE_URL`
- `OPENAI_API_BASE`
jcode appends `/responses` itself, derives the WebSocket and `/compact`
endpoints from the same base, and also points the `/models` catalog probe at it.
The override is ignored in ChatGPT/Codex OAuth mode (that backend is fixed), and
a malformed value is logged and ignored rather than breaking requests.
### Troubleshooting
- Claude 401/auth errors: run `jcode login --provider claude`.
- 401/403: re-run `jcode login --provider openai`.
- Callback issues: make sure port 1455 is free and the browser can reach
`http://localhost:1455/auth/callback`.
## Azure OpenAI
This was added after comparing J-Code to OpenCode/Crush. The meaningful auth gap
was not another browser OAuth flow, but support for **Azure OpenAI** using either:
- **Microsoft Entra ID** credentials (via Azure's `DefaultAzureCredential` chain), or
- **Azure OpenAI API keys**.
### Login/setup steps
1. Run `jcode login --provider azure`.
2. Enter your Azure OpenAI endpoint, for example:
- `https://your-resource.openai.azure.com`
3. Enter your Azure deployment/model name.
4. Choose one auth mode:
- **Entra ID** (recommended)
- **API key**
5. jcode saves settings to `~/.config/jcode/azure-openai.env`.
### Stored configuration
The Azure env file may contain:
- `AZURE_OPENAI_ENDPOINT`
- `AZURE_OPENAI_MODEL`
- `AZURE_OPENAI_USE_ENTRA`
- `AZURE_OPENAI_API_KEY` (only when using key auth)
### Runtime behavior
- jcode normalizes the endpoint to the newer Azure OpenAI `/openai/v1` base.
- In **Entra ID** mode, jcode obtains bearer tokens using `azure_identity::DefaultAzureCredential` with scope:
- `https://cognitiveservices.azure.com/.default`
- In **API key** mode, jcode sends the credential in the Azure-style `api-key` header.
- The Azure provider currently reuses J-Code's OpenAI-compatible transport layer under the hood.
- Model catalog fetching is disabled for Azure by default, so you should configure a deployment/model explicitly.
### Entra ID credential sources
`DefaultAzureCredential` can resolve credentials from sources like:
- `az login`
- managed identity
- Azure environment credentials
### Troubleshooting
- If Entra ID auth fails locally, try `az login` first.
- Make sure your identity has access to the Azure OpenAI resource.
- If requests fail with deployment/model errors, verify `AZURE_OPENAI_MODEL` matches your deployed model name.
- If you prefer static credentials, re-run `jcode login --provider azure` and choose API key mode.
## Gemini OAuth
### Login steps
1. Run `jcode login --provider gemini` or `/login gemini` inside the TUI.
- For headless / SSH use: `jcode login --provider gemini --no-browser`
- For scriptable remote flows: `jcode login --provider gemini --print-auth-url`, then later complete with `--auth-code`
2. jcode opens a browser to the Google OAuth flow used for Gemini Code Assist unless you use `--no-browser`.
3. If local callback binding is unavailable, jcode falls back to a manual paste flow using `https://codeassist.google.com/authcode`.
4. Tokens are saved to `~/.jcode/gemini_oauth.json`.
### Credential discovery order
1. Native jcode Gemini tokens: `~/.jcode/gemini_oauth.json`
2. Gemini CLI OAuth source (read only after approval): `~/.gemini/oauth_creds.json`
3. trusted OpenCode/pi OAuth in `~/.local/share/opencode/auth.json` / `~/.pi/agent/auth.json`
### Runtime notes
- jcode uses native Google OAuth and talks to the Google Code Assist backend directly.
- Expired tokens are refreshed automatically using the Google refresh token.
- Some school / Workspace accounts may require `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` for Code Assist entitlement checks.
### Troubleshooting
- If browser launch fails, use `--no-browser` and the pasted callback/code flow.
- If entitlement or onboarding fails for a Workspace account, set `GOOGLE_CLOUD_PROJECT` and retry.
- If login succeeds but requests fail later, re-run `jcode login --provider gemini` to refresh the stored session.
### Auth verification
Use the built-in auth verifier to test the full local auth/runtime path after login:
```bash
# Run Gemini login now, then verify token refresh + provider smoke
jcode --provider gemini auth-test --login
# Verify existing Gemini auth without re-running login
jcode --provider gemini auth-test
# Check every currently configured supported auth provider
jcode auth-test --all-configured
```
For model providers, `auth-test` attempts:
1. credential discovery
2. refresh/auth probe
3. a real provider smoke prompt expecting `AUTH_TEST_OK`
4. a tool-enabled smoke prompt using the same tool-attached request path as normal chat
Use `--no-tool-smoke` if you only want the auth/simple-runtime checks.
For Gmail/Google it verifies credential discovery and token refresh, but skips model smoke because it is not a model provider.
## OpenAI-compatible API-key providers
J-Code also ships first-class provider presets for many OpenAI-compatible APIs.
These providers use the same built-in login flow pattern: `jcode login --provider <name>`.
For arbitrary OpenAI-compatible APIs, especially when an agent is doing setup, prefer the named profile command instead of hand-editing config:
```bash
printf '%s' "$MY_API_KEY" | jcode provider add my-api \
--base-url https://llm.example.com/v1 \
--model my-model-id \
--api-key-stdin \
--set-default \
--json
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:
### Fireworks
- Login: `jcode login --provider fireworks`
- Stored env file: `~/.config/jcode/fireworks.env`
- API key env var: `FIREWORKS_API_KEY`
- Base URL: `https://api.fireworks.ai/inference/v1`
- Default model hint: `accounts/fireworks/routers/kimi-k2p5-turbo`
- Docs: <https://docs.fireworks.ai/tools-sdks/openai-compatibility>
### MiniMax
- Login: `jcode login --provider minimax`
- Stored env file: `~/.config/jcode/minimax.env`
- API key env var: `OPENAI_API_KEY`
- Base URL: `https://api.minimax.io/v1`
- Default model hint: `MiniMax-M2.7`
- Docs: <https://platform.minimax.io/docs/guides/text-generation>
These are first-class jcode provider presets, not just manual custom endpoint examples.
You can still use `openai-compatible` for arbitrary custom providers when there is not a built-in preset.
If jcode finds matching API keys in trusted OpenCode/pi auth files, it can reuse them for the corresponding provider preset without asking you to paste the key again.
## Experimental CLI Providers
J-Code also supports experimental CLI-backed providers, plus Antigravity with native OAuth login:
- `--provider cursor`
- `--provider copilot`
- `--provider antigravity`
Cursor uses jcode's native HTTPS transport. Copilot uses GitHub device-flow auth. Antigravity login/auth storage is handled natively by jcode.
### Cursor
- Login: `jcode login --provider cursor`
- saves `CURSOR_API_KEY` to `~/.config/jcode/cursor.env`
- Runtime:
- jcode uses native HTTPS requests
- if a Cursor API key is configured, jcode exchanges/uses it directly
- Env vars:
- `JCODE_CURSOR_MODEL` (default: `composer-1.5`)
- `CURSOR_API_KEY` (optional; overrides saved key)
### GitHub Copilot
- Login: `jcode login --provider copilot`
- Headless / SSH: `jcode login --provider copilot --no-browser`
- Scriptable remote flow: `jcode login --provider copilot --print-auth-url`, then later `jcode login --provider copilot --complete`
- jcode uses GitHub device code flow and can print the verification URL/QR without opening a local browser.
- Credential discovery order:
1. `COPILOT_GITHUB_TOKEN`
2. `GH_TOKEN`
3. `GITHUB_TOKEN`
4. trusted `~/.copilot/config.json`
5. trusted legacy `~/.config/github-copilot/hosts.json`
6. trusted legacy `~/.config/github-copilot/apps.json`
7. trusted OpenCode/pi OAuth entries
8. `gh auth token`
- Env vars:
- `JCODE_COPILOT_CLI_PATH` (optional override for CLI path)
- `JCODE_COPILOT_MODEL` (default: `claude-sonnet-4`)
### Antigravity
- Login: `jcode login --provider antigravity` (native Google OAuth flow; does **not** require Antigravity to be installed)
- Headless / SSH: `jcode login --provider antigravity --no-browser`
- Scriptable remote flow: `jcode login --provider antigravity --print-auth-url`, then later complete with `--callback-url`
- Tokens: `~/.jcode/antigravity_oauth.json`
- Credential discovery order:
1. native jcode tokens at `~/.jcode/antigravity_oauth.json`
2. trusted OpenCode/pi OAuth entries when present
- Runtime:
- jcode authenticates directly and stores/refreshes Antigravity OAuth tokens itself
- the provider transport still shells out to the Antigravity CLI for completions if you choose `--provider antigravity`
- Env vars:
- `JCODE_ANTIGRAVITY_CLIENT_ID` (optional override for OAuth client id)
- `JCODE_ANTIGRAVITY_CLIENT_SECRET` (optional override for OAuth client secret)
- `JCODE_ANTIGRAVITY_VERSION` (optional override for Antigravity request fingerprint/version)
- `JCODE_ANTIGRAVITY_CLI_PATH` (default: `antigravity`, runtime only)
- `JCODE_ANTIGRAVITY_MODEL` (default: `default`)
- `JCODE_ANTIGRAVITY_PROMPT_FLAG` (default: `-p`)
- `JCODE_ANTIGRAVITY_MODEL_FLAG` (default: `--model`)
## Google / Gmail OAuth
### Login steps
1. Run `jcode login --provider google`.
- For headless / SSH use: `jcode login --provider google --no-browser`
- For scriptable remote flows after credentials are already configured: `jcode login --provider google --print-auth-url`
2. If Google credentials are not configured yet, jcode first walks you through saving your client ID/client secret or importing the JSON credentials file.
3. For scriptable Google flows, choose the Gmail scope with `--google-access-tier full|readonly` if you do not want the default full access tier.
4. Complete the printed flow later with `jcode login --provider google --callback-url '<full callback url or query>'`.
### Notes
- Google/Gmail scriptable auth requires saved OAuth client credentials first.
- The callback URL can come from a remote browser session that fails on the loopback redirect. Copy the final URL from the address bar and paste or pass it back to jcode.
## Scriptable auth state lifecycle
- jcode stores temporary scriptable login state in `~/.jcode/pending-login/*.json`
- pending state expires automatically
- stale pending entries are cleaned up when scriptable login flows start or resume
- Copilot `--print-auth-url` stores the GitHub device code session and `--complete` resumes polling later
+129
View File
@@ -0,0 +1,129 @@
# Plan: Dynamic Skills and MCP Support
## Goals
1. Hot-reload skills without restart
2. MCP (Model Context Protocol) server support
3. Dynamic tool registration at runtime
4. Agent can add/configure MCP servers itself
## Current State
- Skills: Loaded from `~/.claude/skills/` and `./.claude/skills/` at startup
- Tools: Hardcoded in `Registry::new()`
- No MCP support
---
## Implementation Plan
### Phase 1: Hot-reload Skills
**Changes to `src/skill.rs`:**
- Add `reload(&mut self)` method to `SkillRegistry`
- Skills can be reloaded without restarting
**New tool `reload_skills`:**
- Agent can trigger `reload_skills` to pick up new skills
### Phase 2: Dynamic Tool Registry
**Changes to `src/tool/mod.rs`:**
```rust
impl Registry {
/// Register a new tool at runtime
pub async fn register(&self, tool: Arc<dyn Tool>);
/// Unregister a tool by name
pub async fn unregister(&self, name: &str);
/// List all registered tools
pub async fn list(&self) -> Vec<String>;
}
```
### Phase 3: MCP Client
**New module `src/mcp/mod.rs`:**
- MCP protocol types (JSON-RPC 2.0)
- MCP client for stdio-based servers
- MCP tool wrapper (converts MCP tools to our Tool trait)
**Config file `~/.claude/mcp.json`:**
```json
{
"servers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-filesystem", "/path"],
"env": {}
},
"github": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-github"],
"env": {"GITHUB_TOKEN": "..."}
}
}
}
```
**MCP Manager:**
- Load config on startup
- Connect to configured servers
- Convert MCP tools to jcode Tool trait
- Handle server lifecycle (start, stop, restart)
### Phase 4: Agent Self-Configuration
**New tools:**
- `mcp_list` - List connected MCP servers
- `mcp_connect` - Start a new MCP server
- `mcp_disconnect` - Stop an MCP server
- `mcp_reload` - Reload all MCP servers
**Flow:**
1. Agent calls `mcp_connect {"name": "playwright", "command": "npx", "args": ["-y", "@anthropic/mcp-server-playwright"]}`
2. jcode spawns the process, does MCP handshake
3. Tools from server are added to registry
4. Agent can immediately use the new tools
---
## MCP Protocol Summary
MCP uses JSON-RPC 2.0 over stdio:
**Initialize:**
```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"jcode","version":"0.1.0"}}}
```
**List tools:**
```json
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
```
**Call tool:**
```json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"/tmp/test.txt"}}}
```
---
## Files to Create/Modify
1. `src/mcp/mod.rs` - MCP module
2. `src/mcp/protocol.rs` - JSON-RPC types
3. `src/mcp/client.rs` - MCP client
4. `src/mcp/manager.rs` - Multi-server manager
5. `src/mcp/tool.rs` - MCP tool wrapper
6. `src/tool/mod.rs` - Add dynamic registration
7. `src/tool/mcp_tools.rs` - mcp_connect, mcp_list, etc.
8. `src/skill.rs` - Add reload()
9. `src/tool/reload_skills.rs` - reload_skills tool
## Order of Implementation
1. Dynamic tool registry (prerequisite)
2. Skill hot-reload (quick win)
3. MCP protocol types
4. MCP client (single server)
5. MCP manager (multi-server)
6. MCP tools for agent self-config
+861
View File
@@ -0,0 +1,861 @@
<div align="center">
# jcode
[![Latest Release](https://badgen.net/github/release/1jehuang/jcode?icon=github)](https://github.com/1jehuang/jcode/releases)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
[![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20macOS%20%7C%20Windows-blue?style=flat-square)](https://github.com/1jehuang/jcode/releases)
[![Last Commit](https://badgen.net/github/last-commit/1jehuang/jcode/master?icon=github)](https://github.com/1jehuang/jcode/commits/master)
[![GitHub Stars](https://badgen.net/github/stars/1jehuang/jcode?icon=github)](https://github.com/1jehuang/jcode/stargazers)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/nBe9vGyK9a)
The next generation coding agent harness to raise the skill ceiling. <br>
Built for multi-session workflows, infinite customizability, and performance.
<br>
<a href="https://github.com/1jehuang/jcode/releases/download/readme-assets/jcode-memory-demo.mp4">
<img src="https://github.com/1jehuang/jcode/releases/download/readme-assets/jcode-memory-demo.webp" alt="jcode memory demonstration" width="800">
</a>
<br>
[Website](https://jcode.sh) · [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Further Reading](#further-reading) · [Contributing](CONTRIBUTING.md)
</div>
---
<div align="center">
## Installation
</div>
```bash
# macOS & Linux
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash
```
Need Windows, Homebrew, source builds, provider setup, or tell your agent to set it up for you?
[Jump to detailed installation](#detailed-installation).
---
<div align="center">
## Performance & Resource Efficiency
</div>
jcode is built to be as performant and resource efficient as possible. Every metric is optimized to the bone, which is important for scaling multi-session workflows. Here we sample a few metrics to show the difference: RAM usage and boot up.
### RAM comparison
<div align="center">
<table>
<tr>
<td valign="top" align="center" width="50%">
<strong>1 active session</strong>
<table>
<thead>
<tr>
<th>Tool</th>
<th>PSS</th>
<th>Comparison</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>jcode (local embedding off)</strong></td>
<td align="right"><strong>27.8 MB</strong></td>
<td align="right">baseline</td>
</tr>
<tr>
<td><strong>jcode</strong></td>
<td align="right"><strong>167.1 MB</strong></td>
<td align="right"><strong>6.0× more RAM</strong></td>
</tr>
<tr>
<td><strong>pi</strong></td>
<td align="right"><strong>144.4 MB</strong></td>
<td align="right"><strong>5.2× more RAM</strong></td>
</tr>
<tr>
<td><strong>Codex CLI</strong></td>
<td align="right"><strong>140.0 MB</strong></td>
<td align="right"><strong>5.0× more RAM</strong></td>
</tr>
<tr>
<td><strong>OpenCode</strong></td>
<td align="right"><strong>371.5 MB</strong></td>
<td align="right"><strong>13.4× more RAM</strong></td>
</tr>
<tr>
<td><strong>GitHub Copilot CLI</strong></td>
<td align="right"><strong>333.3 MB</strong></td>
<td align="right"><strong>12.0× more RAM</strong></td>
</tr>
<tr>
<td><strong>Cursor Agent</strong></td>
<td align="right"><strong>214.9 MB</strong></td>
<td align="right"><strong>7.7× more RAM</strong></td>
</tr>
<tr>
<td><strong>Claude Code</strong></td>
<td align="right"><strong>386.6 MB</strong></td>
<td align="right"><strong>13.9× more RAM</strong></td>
</tr>
<tr>
<td><strong>Antigravity CLI</strong></td>
<td align="right"><strong>243.7 MB</strong></td>
<td align="right"><strong>8.8× more RAM</strong></td>
</tr>
</tbody>
</table>
</td>
<td width="24"></td>
<td valign="top" align="center" width="50%">
<strong>10 active sessions</strong>
<table>
<thead>
<tr>
<th>Tool</th>
<th>PSS</th>
<th>Comparison</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>jcode (local embedding off)</strong></td>
<td align="right"><strong>117.0 MB</strong></td>
<td align="right">baseline</td>
</tr>
<tr>
<td><strong>jcode</strong></td>
<td align="right"><strong>260.8 MB</strong></td>
<td align="right"><strong>2.2× more RAM</strong></td>
</tr>
<tr>
<td><strong>pi</strong></td>
<td align="right"><strong>833.0 MB</strong></td>
<td align="right"><strong>7.1× more RAM</strong></td>
</tr>
<tr>
<td><strong>Codex CLI</strong></td>
<td align="right"><strong>334.8 MB</strong></td>
<td align="right"><strong>2.9× more RAM</strong></td>
</tr>
<tr>
<td><strong>OpenCode</strong></td>
<td align="right"><strong>3237.2 MB</strong></td>
<td align="right"><strong>27.7× more RAM</strong></td>
</tr>
<tr>
<td><strong>GitHub Copilot CLI</strong></td>
<td align="right"><strong>1756.5 MB</strong></td>
<td align="right"><strong>15.0× more RAM</strong></td>
</tr>
<tr>
<td><strong>Cursor Agent</strong></td>
<td align="right"><strong>1632.4 MB</strong></td>
<td align="right"><strong>14.0× more RAM</strong></td>
</tr>
<tr>
<td><strong>Claude Code</strong></td>
<td align="right"><strong>2300.6 MB</strong></td>
<td align="right"><strong>19.7× more RAM</strong></td>
</tr>
<tr>
<td><strong>Antigravity CLI</strong></td>
<td align="right"><strong>1021.2 MB</strong></td>
<td align="right"><strong>8.7× more RAM</strong></td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
### Time to first frame
<div align="center">
| Tool | Time to first frame | Range | Comparison |
|---|---:|---:|---:|
| **jcode** | **14.0 ms** | 10.119.3 ms | baseline |
| **Antigravity CLI** | **383.5 ms** | 363.1415.4 ms | **27.4× slower** |
| **pi** | **590.7 ms** | 369.6934.8 ms | **42.2× slower** |
| **Codex CLI** | **882.8 ms** | 742.31640.9 ms | **63.1× slower** |
| **OpenCode** | **1035.9 ms** | 922.51104.4 ms | **74.0× slower** |
| **GitHub Copilot CLI** | **1518.6 ms** | 1357.41826.8 ms | **108.5× slower** |
| **Cursor Agent** | **1949.7 ms** | 1711.02104.8 ms | **139.3× slower** |
| **Claude Code** | **3436.9 ms** | 2032.78927.2 ms | **245.5× slower** |
</div>
Measured on this Linux machine across 10 interactive PTY launches.
### Time to first input
(time until typed probe text appears on the rendered screen; Antigravity uses its internal input-ready log marker because the sign-in screen suppresses probe echo.)
<div align="center">
| Tool | Time to first input | Range | Comparison |
|---|---:|---:|---:|
| **jcode** | **48.7 ms** | 30.362.7 ms | baseline |
| **Antigravity CLI** | **383.7 ms** | 363.4415.7 ms | **7.9× slower** |
| **pi** | **596.4 ms** | 373.9955.2 ms | **12.2× slower** |
| **Codex CLI** | **905.8 ms** | 760.11675.7 ms | **18.6× slower** |
| **OpenCode** | **1047.9 ms** | 931.11116.9 ms | **21.5× slower** |
| **GitHub Copilot CLI** | **1583.4 ms** | 1422.81880.0 ms | **32.5× slower** |
| **Cursor Agent** | **1978.7 ms** | 1727.32130.0 ms | **40.6× slower** |
| **Claude Code** | **3512.8 ms** | 2137.49002.0 ms | **72.2× slower** |
</div>
Measured on this Linux machine across 10 interactive PTY launches. Antigravity CLI was unauthenticated for this run; its sign-in screen rendered normally and emitted an internal `CLI ready for user input` marker, but did not echo the typed probe.
### Additional clients / memory scaling
<div align="center">
| Tool | Extra PSS per added session | Comparison |
|---|---:|---:|
| **jcode (local embedding off)** | **~9.9 MB** | baseline |
| **jcode** | **~10.4 MB** | **1.1× more RAM** |
| **pi** | **~76.5 MB** | **7.7× more RAM** |
| **Codex CLI** | **~21.6 MB** | **2.2× more RAM** |
| **OpenCode** | **~318.4 MB** | **32.2× more RAM** |
| **GitHub Copilot CLI** | **~158.1 MB** | **16.0× more RAM** |
| **Cursor Agent** | **~157.5 MB** | **15.9× more RAM** |
| **Claude Code** | **~212.7 MB** | **21.5× more RAM** |
| **Antigravity CLI** | **~86.4 MB** | **8.7× more RAM** |
</div>
versions tested for this corrected memory rerun:
- `jcode v0.9.1888-dev (be386f2)`
- `pi 0.62.0`
- `codex-cli 0.120.0`
- `opencode 1.0.203`
- `GitHub Copilot CLI 1.0.24` for the 1-session rerun, `GitHub Copilot CLI 1.0.27` for the 10-session rerun
- `Cursor Agent 2026.04.08-a41fba1`
- `Claude Code 2.1.86 (Claude Code)`
- `Antigravity CLI 1.0.0`
<div align="center">
<a href="https://github.com/1jehuang/jcode/releases/download/readme-assets/jcode-performance-demo.mp4">
<img src="https://github.com/1jehuang/jcode/releases/download/readme-assets/jcode-performance-demo.webp" alt="jcode performance demonstration" width="900">
</a>
<p><em>jcode performance demonstration</em></p>
</div>
---
## Memory (Agent memory)
Jcode embeds each turn/response as a semantic vector. Every turn does queries a graph of memories to efficiently find related memory entries via a cosine similarity check. The embedding hits are fed into the conversation, or optionally uses a memory sideagent which verifies the memories are relevant, and potentially does more work for information retreival before injecting into the conversation. This results in a human like memory system which allows the agent to automatically recall relevant information to the conversation without actively calling memory tools or being a token burner.
ot
To have memories which are retrieved, they must also be extracted and stored. Every so often (semantic drift, K turns since last extraction, session end, etc), memories are extracted via a memory sideagent, and put into the memory graph.
The harness also provides explicit memory tools to allow the agent to actively search or store the memory without relying on a passive background process. The harness also provides session search for traditional RAG on previous sessions.
Memories are automatically consolidated every so often via the ambient mode. This reorganizes, checks for staleness and conflicts, etc
<div align="center">
<a href="https://github.com/1jehuang/jcode/releases/download/readme-assets/jcode-memory-demo.mp4">
<img src="https://github.com/1jehuang/jcode/releases/download/readme-assets/jcode-memory-demo.webp" alt="jcode memory demonstration" width="900">
</a>
<p><em>jcode memory demonstration</em></p>
</div>
<!-- Memory demo media is hosted in the readme-assets release. -->
---
## UI: Side panels, Diagrams, Info Widgets, rendering, scrolling, alignment
The side panel is a place for auxiliary information. Tell your jcode agent to load a file into the side panel and see it update in real time, or tell your agent to write directly to the side panel, or use it as a diff viewer. The side panel (and chat) is able to render mermaid diagrams inline.
<img width="2877" height="1762" alt="image" src="https://github.com/user-attachments/assets/6c7bec81-ef3f-434d-8a7b-d55f8a54e5cf" />
To make this possible, I created a new mermaid rendering library to render diagrams 1800x faster. It has no browser or Typescript dependency. See https://github.com/1jehuang/mermaid-rs-renderer
To show you important information without taking space away from the screen that could be used for responses, I developed info widgets. Info widgets will only ever take up the negative space on the screen to show you information, and will get out of the way if there isn't any.
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.
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.
---
## Swarm
Spawn two or more agents in the same repo, and they will automatically be managed by the server to allow native collaboration. When agent A edits a file that agent B has read (code shifting under its feet), the server notifies agent B. Agent B can ignore it if it is not relevant, or it can check the diff to make sure that it doesn't conflict. Each agent has messaging abilities, capable of DMing just one agent, broadcasting to all other agents hosted by the server, or just agents working in that repo. This allows you to spawn multiple sessions in the same repo, and have all conflicts automatically resolved.
<div align="center">
<a href="https://github.com/1jehuang/jcode/releases/download/readme-assets/swarm-demo.mp4">
<img src="https://github.com/1jehuang/jcode/releases/download/readme-assets/jcode-swarm-demonstration.webp" alt="jcode swarm demonstration" width="900">
</a>
<p><em>jcode swarm demonstration</em></p>
</div>
Agents are also able to spawn their own swarms autonomously. They have a swarm tool which allows them to spawn in their own teamates to accomplish tasks in parallel. Doing so turns the main agent into a coordinator and the spawned agents into workers. Groups of agents, their messaging channels, their completion statuses, etc are all automatically managed. This can be done headlessly or headed.
---
## OAuth and Providers
jcode works with subscription-backed OAuth flows and many provider integrations, so you can use the models you already pay for and still fall back to direct API providers when needed.
### Supported built-in login flows
- **Claude** (`jcode login --provider claude`)
- **OpenAI / ChatGPT / Codex** (`jcode login --provider openai`)
- **Google Gemini** (`jcode login --provider gemini`)
- **GitHub Copilot** (`jcode login --provider copilot`)
- **Azure OpenAI** (`jcode login --provider azure`)
- **Alibaba Cloud Coding Plan** (`jcode login --provider alibaba-coding-plan`)
- **Fireworks** (`jcode login --provider fireworks`)
- **MiniMax** (`jcode login --provider minimax`)
- **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.
### 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.
#### OpenAI-compatible providers
Many hosted services speak the standard OpenAI `/v1/chat/completions` API. jcode talks to them through one shared OpenAI-compatible provider, so you can use almost any such endpoint without waiting for a dedicated integration.
There are two ways to set one up:
- **Built-in named profiles** — jcode ships ready-made profiles for several popular OpenAI-compatible services. Log in by id and jcode fills in the base URL and key environment variable for you:
```bash
jcode login --provider <profile-id>
# for example:
jcode login --provider openrouter
jcode login --provider deepseek
jcode login --provider opencode # OpenCode Zen
jcode login --provider moonshotai
```
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.
- **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`.
- Per-model `context_window` (alias `context_limit`) in a `[[providers.<name>.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.
For details on self-hosting, local runtimes, and the exact config file shape, see below.
#### Self-hosted OpenAI-compatible endpoints, including vLLM
For agents and scripts, the preferred path is the one-shot provider profile command. It writes a named profile to `~/.jcode/config.toml`, stores secrets in jcode's private app config directory when requested, and prints exact run/validation commands:
```bash
# Secret-safe setup for a hosted OpenAI-compatible API.
printf '%s' "$MY_API_KEY" | jcode provider add my-api \
--base-url https://llm.example.com/v1 \
--model my-model-id \
--api-key-stdin \
--set-default \
--json
# Smoke test the profile.
jcode --provider-profile my-api auth-test --prompt 'Reply exactly JCODE_PROVIDER_SETUP_OK'
# Use it directly.
jcode --provider-profile my-api run 'hello'
```
For local servers that do not require auth:
```bash
jcode provider add local-vllm \
--base-url http://localhost:8000/v1 \
--model Qwen/Qwen3-Coder-30B-A3B-Instruct \
--no-api-key \
--set-default
```
Built-in local profiles are available for the common desktop/local runtimes:
```bash
# Ollama: start the local server and install a model first.
ollama pull llama3.2
jcode login --provider ollama
jcode --provider ollama --model llama3.2 run 'hello'
# LM Studio: start the Local Server, load a chat model, then use the exact
# model identifier shown by LM Studio or by curl http://localhost:1234/v1/models.
jcode login --provider lmstudio
jcode --provider lmstudio --model '<model-id>' run 'hello'
```
Ollama and LM Studio both expose OpenAI-compatible `/v1/models` and `/v1/chat/completions` endpoints. jcode uses streaming chat completions, function/tool calling, and OpenAI-style image content for vision-capable local models. If a local server requires a token, enter it during `jcode login` or create a named profile with `--api-key-stdin`.
Useful flags:
- `--api-key-env NAME`: reference an existing environment variable instead of storing a key.
- `--api-key-stdin`: read and store a key without putting it in shell history.
- `--context-window TOKENS`: persist the model context window for model selection and routing.
- `--overwrite`: replace an existing profile of the same name.
- `--model-catalog`: use the endpoint's `/models` response in addition to configured models.
The generated profile can also be edited manually in `~/.jcode/config.toml`:
```toml
[provider]
default_provider = "my-api"
default_model = "my-model-id"
[providers.my-api]
type = "openai-compatible"
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"
[[providers.my-api.models]]
id = "my-model-id"
context_window = 128000
```
##### 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.
1. Per named profile, via `extra_body` in `config.toml` (a TOML table merged verbatim into the JSON body):
```toml
[providers.my-nim]
type = "openai-compatible"
base_url = "https://integrate.api.nvidia.com/v1"
api_key_env = "NVIDIA_API_KEY"
default_model = "deepseek-ai/deepseek-v4-flash"
[providers.my-nim.extra_body.chat_template_kwargs]
thinking = true
reasoning_effort = "high"
```
2. For built-in profiles (e.g. `nvidia-nim`) or any endpoint, via the `JCODE_OPENAI_EXTRA_BODY` environment variable (a JSON object string). It can live in the provider's env file (`~/.config/jcode/nvidia-nim.env`) next to the API key:
```bash
JCODE_OPENAI_EXTRA_BODY={"chat_template_kwargs":{"thinking":true,"reasoning_effort":"high"}}
```
Keys from `extra_body` are merged last and override any jcode-generated body field with the same name (`JCODE_OPENAI_EXTRA_BODY` wins over the config `extra_body` on key collisions). Invalid values are logged and ignored rather than failing the request.
The custom OpenAI-compatible provider reads overrides from environment variables or from an env file in jcode's app config directory. On Linux this is usually `~/.config/jcode/`, so the default file is usually:
```text
~/.config/jcode/openai-compatible.env
```
Example for a local or LAN vLLM server:
```bash
JCODE_OPENAI_COMPAT_API_BASE=http://192.168.1.50:8000/v1
JCODE_OPENAI_COMPAT_DEFAULT_MODEL=Qwen/Qwen3-Coder-30B-A3B-Instruct
# Optional if your server expects auth
OPENAI_COMPAT_API_KEY=your-token-here
```
Notes:
- `jcode login --provider openai-compatible` can create or update this for you.
- Plain `http://` is accepted for `localhost` and private LAN IPs. Public remote HTTP is still rejected.
- HTTPS endpoints work as usual.
#### MCP config files
MCP config is separate from `config.toml`.
Primary config files:
- `~/.jcode/mcp.json` for global MCP servers
- `.jcode/mcp.json` for project-local MCP servers
Claude Code compatibility:
- `~/.claude.json` (Claude Code's user config): top-level `mcpServers`, plus per-project servers under `projects.<abs_path>.mcpServers` for the current directory
- `.mcp.json` at the repo root (Claude Code's project config)
- `.claude/mcp.json` (legacy fallback)
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:
```json
{
"mcpServers": {
"filesystem": {
"command": "/path/to/mcp-server",
"args": ["--root", "/workspace"],
"env": {},
"shared": true
}
}
}
```
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.
For headless or SSH sessions, OAuth-style providers support `jcode login --provider <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.
For more scriptable remote flows, `claude`, `openai`, `gemini`, and `antigravity` also support a two-step pattern:
```bash
# Step 1: print a resumable auth URL
jcode login --provider openai --print-auth-url --json
# Step 2: complete later with the callback URL or auth code
jcode login --provider openai --callback-url 'http://localhost:1455/auth/callback?...'
jcode login --provider gemini --auth-code '...'
```
Additional scriptable cases:
```bash
# Copilot device flow: print URL + user code, then complete later
jcode login --provider copilot --print-auth-url --json
jcode login --provider copilot --complete
# Gmail/Google OAuth after credentials are already configured
jcode login --provider google --print-auth-url --google-access-tier readonly
jcode login --provider google --callback-url 'http://127.0.0.1:8456?...'
```
Pending scriptable login state is stored under `~/.jcode/pending-login/`, automatically expires, and stale entries are cleaned up when new scriptable logins start or resume.
For the built-in OpenAI login flow, jcode opens a local callback on
`http://localhost:1455/auth/callback` by default.
<img width="2877" height="1762" alt="Screenshot from 2026-04-02 14-28-51" src="https://github.com/user-attachments/assets/530684c0-9d12-4363-aa0e-1b39a0d4e1be" />
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`
Jcode also supports easy multi-account switching. Ran out of tokens on your first ChatGPT Pro subscription? /account and quickly switch to your second.
---
## Customizability / Self-Dev
Jcode is inventing a new form of customizability. One that doesn't limit you to what a plugin or extension can do. Tell your jcode agent to enter self dev mode, and it will start modifying its own source code. Jcode is optimized to iterate on itself. There is significant infrastructure around self developement, which allows it to edit, build, and test its own source code, then reload its own binary and continue work in your (potentially many) sessions, fully automatically.
It is reccomended that you use a frontier model for this. The jcode codebase is not a simple one, and weaker models can make subtle, breaking changes. GPT 5.5 or the latest available frontier model works well.
<!-- Add self-dev demo thumbnail/video and fuller writeup here. -->
---
## Misc.
The devil is in the details. There are many undocumented optimizations and niceties that jcode implements. Some examples:
Anthropic's Claude cache goes cold after 5 minutes. If you initiate Claude after these 5 minutes, you have a cache miss, potentially costing you lots of tokens. The ui warns you when the cache went cold, and notfies you if there was an unexpected cache miss.
jcode comes with instructions on how to set up Firefox Agent Bridge. Ask you agent to set it up, and then you will have browser automation in jcode as well.
Agent grep is a grep tool I made for the jcode agent. It adds file strucuture information (ie the list of functions, their displacement, etc) to the grep return, so that the agent can infer more of what the file doesn without actually reading the file. It also implements a harness-level integration that adaptively truncates returns based on what the agent has already seen. This saves on context a lot.
Inputs are by default interleaved with the working agent. It sends the input as soon as it safely can without breaking the KV cache. Submit with shift enter instead, and it will send a queue send, and wait for the agent to fully finish its turn before sending.
Resume sessions from different harnesses. Claude code broke on you? Resume the session from jcode and continue where you left off. Session resume is supported for codex, claude code, opencode, and pi.
<img width="2877" height="1762" alt="Screenshot from 2026-04-11 16-28-52" src="https://github.com/user-attachments/assets/c2b383cf-2531-4217-85ae-6a863354dc97" />
image of /Resume for codex sessions
Skills are not all loaded on startup. The conversation is embedded as a semantic vector, and will automatically inject a skill if there is an embedding hit similar to memories. The agent has a skill tool for you to manually activate a skill at anytime. You may also activate via slash commands.
---
## iOS Application / Native OpenClaw
A native iOS application version of jcode is coming soon. This will allow you to work with jcode on your personal machine's environment from your phone, via Tailscale. Openclaw like features will be bundled with this iOS application.
---
## Other planned features
Agents dont like to commit in dirty git state with active changes. Git was clearly not built for multi-agent workflows, and git worktrees is not a good solution. Given this, I believe that is an opporunity for a new git like primitive to be born.
Build speed improvements: An incremental debug cargo build with cache enabled takes about 1 minute on my machine. The goal is 5-20 seconds. Refactors and crates seams should be able to make this happen.
<!-- Add iOS / native OpenClaw preview and fuller writeup here. -->
---
<div align="center">
## Quick Start
</div>
```bash
# Launch the TUI
jcode
# Run a single command non-interactively
jcode run "say hello"
# Resume a previous session by memorable name
jcode --resume fox
# Run as a persistent background server, then attach more clients
jcode serve
jcode connect
# Send voice input from your configured STT command
jcode dictate
```
jcode supports interactive TUI use, non-interactive runs, persistent server/client workflows,
and hotkey-friendly dictation without requiring a bundled speech-to-text stack.
<div align="center">
<a href="https://github.com/1jehuang/jcode/releases/download/readme-assets/workflow.mp4">
<img src="https://github.com/1jehuang/jcode/releases/download/readme-assets/jcode-workflow-demonstration.webp" alt="jcode workflow demonstration" width="900">
</a>
<p><em>jcode workflow demonstration</em></p>
</div>
---
## Browser Automation
jcode includes a first-class built-in `browser` tool for browser control inside agent sessions.
Current built-in backend:
- Firefox via Firefox Agent Bridge
Current built-in tool actions include:
- `status`
- `setup`
- `open`
- `snapshot`
- `get_content`
- `interactables`
- `click`
- `type`
- `fill_form`
- `select`
- `wait`
- `screenshot`
- `eval`
- `scroll`
- `upload`
- `press`
Quick setup:
```bash
jcode browser status
jcode browser setup
```
Once setup is complete, the model can use the built-in `browser` tool directly. The UI also summarizes browser tool calls compactly, for example opening a URL, clicking a selector, or typing into a field without echoing sensitive typed text.
Notes:
- the provider/tool architecture is in place for additional backends
- Firefox is the wired built-in backend today
- Chrome bridge / remote debugging style providers can be added on top of the same browser tool later
---
## Further Reading
- [Ambient Mode / OpenClaw](docs/AMBIENT_MODE.md)
- [Browser Provider Protocol](docs/BROWSER_PROVIDER_PROTOCOL.md)
- [Memory Architecture](docs/MEMORY_ARCHITECTURE.md)
- [Swarm Architecture](docs/SWARM_ARCHITECTURE.md)
- [Server Architecture](docs/SERVER_ARCHITECTURE.md)
- [Safety System](docs/SAFETY_SYSTEM.md)
- [Sponsored Discovery Sponsor Onboarding](docs/SPONSORED_DISCOVERY_SPONSOR_ONBOARDING.md)
- [Windows Notes](docs/WINDOWS.md)
- [Wrappers and Shell Integration](docs/WRAPPERS.md)
- [Refactoring Notes](docs/REFACTORING.md)
---
## Detailed Installation
### Setup
If you want another agent to set up jcode for you, give it this prompt:
```text
Set up jcode on this machine for me.
1. Detect the operating system, available package managers, and shell environment, then install jcode using the best matching command below instead of referring me somewhere else:
- macOS with Homebrew available:
brew tap 1jehuang/jcode
brew install jcode
- macOS or Linux via install script:
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash
- Windows PowerShell:
irm https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.ps1 | iex
- From source if the above paths are not appropriate:
git clone https://github.com/1jehuang/jcode.git
cd jcode
cargo build --release
scripts/install_release.sh
- For local self-dev / refactor work on Linux x86_64, prefer:
scripts/dev_cargo.sh build --release -p jcode --bin jcode
scripts/dev_cargo.sh --print-setup
scripts/install_release.sh
2. Verify that `jcode` is on my `PATH`.
3. Launch `jcode` once in a new terminal window/session to confirm it starts successfully.
4. Before attempting any interactive login flow, assess which providers are already available non-interactively and prefer those first. Check existing local credentials, config files, CLI sessions, and environment variables such as:
- Claude: `~/.jcode/auth.json`, `~/.claude/.credentials.json`, `~/.local/share/opencode/auth.json`, `ANTHROPIC_API_KEY`
- OpenAI: `~/.jcode/openai-auth.json`, `~/.codex/auth.json`, `OPENAI_API_KEY`
- Gemini: `~/.jcode/gemini_oauth.json`, `~/.gemini/oauth_creds.json`
- GitHub Copilot: existing auth under `~/.config/github-copilot/`
- 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`
- 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
5. Prefer whichever provider is already configured and verify it with `jcode auth-test --all-configured` or a provider-specific auth test when appropriate.
6. Only if no usable provider is already configured, guide me through the minimal manual step needed:
- Claude: `jcode login --provider claude`
- GitHub Copilot: `jcode login --provider copilot`
- OpenAI: `jcode login --provider openai`
- Gemini: `jcode login --provider gemini`
- Azure OpenAI: `jcode login --provider azure`
- Fireworks: `jcode login --provider fireworks`
- MiniMax: `jcode login --provider minimax`
- NVIDIA NIM: `jcode login --provider nvidia-nim`
- Alibaba Cloud Coding Plan: `jcode login --provider alibaba-coding-plan`
- OpenRouter: help me set `OPENROUTER_API_KEY`
- Anthropic direct API: help me set `ANTHROPIC_API_KEY`
7. After setup, run a simple smoke test with `jcode run "say hello"` and confirm it works.
8. If I want browser automation, also check `jcode browser status`. If browser automation is not ready, run `jcode browser setup`, verify the built-in `browser` tool works, and explain any remaining manual step.
9. Explain any manual step that still needs me, especially browser OAuth, device login, API key entry, or browser extension approval.
```
This is intended to be a copy-paste bootstrap prompt for jcode itself or any other coding agent.
### Quick Install
```bash
# macOS & Linux
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash
```
On Termux, install the glibc runtime and `patchelf` first so the installer can
patch the downloaded Linux binary to Termux's glibc dynamic linker and create a
launcher that avoids Termux's `LD_PRELOAD` shim:
```bash
pkg install glibc patchelf
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash
```
```powershell
# Windows (PowerShell)
irm https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.ps1 | iex
```
### macOS via Homebrew
```bash
brew tap 1jehuang/jcode
brew install jcode
```
### From Source (all platforms)
```bash
git clone https://github.com/1jehuang/jcode.git
cd jcode
cargo build --release
```
For local self-dev / refactor work on Linux x86_64, prefer:
```bash
scripts/dev_cargo.sh build --release -p jcode --bin jcode
scripts/dev_cargo.sh --print-setup
```
That wrapper automatically uses `sccache` when available, prefers a fast
working local linker setup (`clang + lld`) instead of assuming every machine's
`mold` configuration is valid, and can print the active linker/cache setup via
`--print-setup` so slow-path builds are easier to diagnose.
Then symlink to your PATH:
```bash
scripts/install_release.sh
```
### Uninstall
Removes installed binaries and the launcher but keeps your config, auth, and
sessions so a clean reinstall picks up where you left off:
```bash
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/uninstall.sh | bash -s -- --yes
```
For a full wipe of everything including config, auth, sessions, logs, and
memory (useful for recovering from a broken install):
```bash
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/uninstall.sh | bash -s -- --purge --yes
```
Add `--dry-run` to preview what would be removed without deleting anything.
### Platform Support
| Platform | Status |
|---|---|
| **Linux** x86_64 / aarch64 | Fully supported |
| **macOS** Apple Silicon & Intel | Supported |
| **Windows** x86_64 | Supported (native + WSL2) |
| **Termux** aarch64 / x86_64 | Supported with `pkg install glibc patchelf` |
</div>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`1jehuang/jcode`
- 原始仓库:https://github.com/1jehuang/jcode
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+159
View File
@@ -0,0 +1,159 @@
# Releasing jcode
jcode has two release paths: a fast local path for hotfixes, and CI for full releases.
## Quick Release (local, ~2.5 minutes)
For hotfixes and urgent updates. Builds Linux + macOS locally and uploads directly.
```bash
scripts/quick-release.sh v0.5.5 # Build + tag + release
scripts/quick-release.sh v0.5.5 "Fix bug" # With custom title
scripts/quick-release.sh --dry-run v0.5.5 # Build only, don't publish
```
### How it works
1. Builds Linux x86_64 natively and macOS aarch64 via osxcross **in parallel**
2. Verifies both binaries (ELF and Mach-O checks)
3. Creates a git tag and pushes it (this also triggers CI for the Windows build)
4. Uploads both binaries to a GitHub Release via `gh release create`
5. Users can immediately run `jcode update`
### Prerequisites
Already set up on the dev laptop (xps13):
- **osxcross** at `~/.osxcross` with macOS 14.5 SDK (darwin triple: `aarch64-apple-darwin23.5`)
- **rustup** with `aarch64-apple-darwin` target installed
- **`~/.cargo/config.toml`** has the osxcross linker configured
- **`gh` CLI** authenticated with GitHub
### Timeline
```
0s Start parallel builds (Linux native + macOS cross-compile)
~90s Linux build finishes
~150s macOS build finishes
~153s Binaries uploaded, release live
✅ Linux + macOS users can `jcode update`
~16m CI finishes Windows build, uploads to same release
✅ Windows users can `jcode update`
```
## CI Release (automated, ~11 min Linux+macOS, ~16 min Windows)
Triggered automatically when a `v*` tag is pushed to GitHub.
### Workflow: `.github/workflows/release.yml`
```
Tag push (v*)
├─► build-linux-macos (parallel)
│ ├─► Linux x86_64 (ubuntu-latest) ~8 min
│ └─► macOS aarch64 (macos-latest) ~11 min
├─► build-windows (parallel, non-blocking)
│ ├─► Windows x86_64 (windows-latest) ~16 min
│ └─► Windows ARM64 (windows-11-arm) ~16 min
├─► release (after Linux + macOS complete)
│ ├─► Create GitHub Release with binaries
│ ├─► Update Homebrew formula (1jehuang/homebrew-jcode)
│ └─► Update AUR package (jcode-bin)
└─► upload-windows-assets (after Windows + release complete)
└─► Upload Windows binaries to existing release
```
Key design decisions:
- **Windows does not block the release.** Linux and macOS binaries are published as soon as they're ready. Windows is added later.
- **Shallow clones** (`fetch-depth: 1`) to minimize checkout time.
- **`CARGO_INCREMENTAL=0`** for CI (incremental adds overhead on clean CI builds).
- **sccache + rust-cache** for dependency caching across runs.
- **mold linker** on Linux for faster linking.
### Package manager updates
CI handles Homebrew and AUR updates automatically:
- **Homebrew**: Updates `Formula/jcode.rb` in `1jehuang/homebrew-jcode` with new SHA256 hashes
- **AUR**: Updates `PKGBUILD` and `.SRCINFO` in the `jcode-bin` AUR repo
Both are triggered by the `release` job after Linux + macOS builds complete.
## Which to use
| Scenario | Method | Time to Linux+macOS | Time to Windows |
|----------|--------|-------------------|-----------------|
| Hotfix / urgent bug | `scripts/quick-release.sh` | **~2.5 min** | ~16 min (CI) |
| Regular release | Push `v*` tag | ~11 min | ~16 min |
| Need Homebrew/AUR | Push `v*` tag | ~11 min | ~16 min |
For quick releases that also need Homebrew/AUR updates, use the script first (gets binaries out fast), then the CI tag push handles the package manager updates automatically. CI's `softprops/action-gh-release` will update the existing release created by the script.
## Cross-Compilation Setup
macOS binaries are cross-compiled from Linux using [osxcross](https://github.com/tpoechtrager/osxcross).
### Current configuration
| Component | Value |
|-----------|-------|
| SDK | macOS 14.5 |
| SDK source | [joseluisq/macosx-sdks](https://github.com/joseluisq/macosx-sdks) |
| Install location | `~/.osxcross/` |
| Darwin triple | `aarch64-apple-darwin23.5` |
| Linker | `aarch64-apple-darwin23.5-clang` |
### Cargo config (`~/.cargo/config.toml`)
```toml
[target.aarch64-apple-darwin]
linker = "aarch64-apple-darwin23.5-clang"
[env]
CC_aarch64_apple_darwin = "aarch64-apple-darwin23.5-clang"
CXX_aarch64_apple_darwin = "aarch64-apple-darwin23.5-clang++"
```
### Rebuilding osxcross from scratch
```bash
git clone https://github.com/tpoechtrager/osxcross /tmp/osxcross
curl -L -o /tmp/osxcross/tarballs/MacOSX14.5.sdk.tar.xz \
https://github.com/joseluisq/macosx-sdks/releases/download/14.5/MacOSX14.5.sdk.tar.xz
cd /tmp/osxcross && UNATTENDED=1 TARGET_DIR=~/.osxcross ./build.sh
rustup target add aarch64-apple-darwin
```
Build takes ~5 minutes. Requires `clang`, `cmake`, `libxml2` (all available via pacman on Arch).
### Why osxcross (not zigbuild)
`cargo-zigbuild` can cross-compile pure Rust code to macOS, but jcode depends on crates that link against macOS system frameworks:
- `arboard` (clipboard) - links `AppKit`, `Foundation`
- `native-tls` / `security-framework` - links `Security`, `SystemConfiguration`
- `objc2` - links Objective-C runtime
These require actual macOS SDK headers and framework stubs, which osxcross provides.
## Build Performance
### Current timing (laptop, 8-core Intel Ultra 7 256V)
| Build | Clean | Cached deps |
|-------|-------|-------------|
| Linux x86_64 (native) | ~90s | ~90s |
| macOS aarch64 (cross) | ~3 min | ~2.5 min |
| Both in parallel | ~3 min | ~2.5 min |
The bottleneck is compiling jcode itself (120k lines of Rust). Dependencies are cached and don't need recompilation. The `build.rs` timestamp causes a full recompile of the main crate on every build.
### Why not faster
- `opt-level = 1`, `codegen-units = 256`, `incremental = true` are already set in `[profile.release]`
- 8 cores is the hardware limit
- Splitting into workspace crates would allow partial recompilation (~1 min for small changes)
- A 20+ core machine on LAN (not Tailscale) would cut build time to ~40-50s
+284
View File
@@ -0,0 +1,284 @@
# 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.
## What We Collect
### Install Event (sent once, on first launch)
| Field | Example | Purpose |
|-------|---------|----------|
| `id` | `a1b2c3d4-...` | Random UUID, not tied to your identity |
| `event` | `"install"` | Event type |
| `version` | `"0.6.0"` | jcode version |
| `os` | `"linux"` | Operating system |
| `arch` | `"x86_64"` | CPU architecture |
### Upgrade Event
| Field | Example | Purpose |
|-------|---------|----------|
| `id` | `a1b2c3d4-...` | Same random UUID |
| `event` | `"upgrade"` | Event type |
| `version` | `"0.9.1"` | Current jcode version |
| `from_version` | `"0.8.1"` | Previously recorded jcode version |
| `os` / `arch` | `"linux"` / `"x86_64"` | Environment breakdown |
### Auth Success Event
| Field | Example | Purpose |
|-------|---------|----------|
| `id` | `a1b2c3d4-...` | Same random UUID |
| `event` | `"auth_success"` | Event type |
| `auth_provider` | `"claude"` | Which provider/account system was configured |
| `auth_method` | `"oauth"` | Coarse auth method only |
| `version` / `os` / `arch` | `"0.9.1"` / `"linux"` / `"x86_64"` | Activation funnel dimensions |
### Onboarding Step Event
| Field | Example | Purpose |
|-------|---------|----------|
| `event` | `"onboarding_step"` | Event type |
| `step` | `"first_prompt_sent"` | Coarse funnel step |
| `auth_provider` | `"openai"` | Optional provider dimension for auth steps |
| `auth_method` | `"oauth"` | Optional auth-method dimension for auth steps |
| `milestone_elapsed_ms` | `42000` | Rough time from install to milestone |
### Feedback Event
| Field | Example | Purpose |
|-------|---------|----------|
| `event` | `"feedback"` | Event type |
| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback explicitly submitted with `/feedback ...` |
| `feedback_rating` | `"up"` / `"down"` | Legacy explicit product sentiment, if present |
| `feedback_reason` | `"slow"` | Legacy optional coarse reason bucket, if present |
### Sponsored Discovery Event
One event is sent after each `discover_tools` attempt. A random per-request ID
is also sent to the discovery API as the `x-jcode-discovery-request-id` header,
allowing client reliability telemetry to be correlated with server request logs
without exposing prompts or a persistent telemetry identifier to that service.
| Field | Example | Purpose |
|-------|---------|----------|
| `event` | `"discovery"` | Event type |
| `request_id` | `"9a23..."` | Random correlation ID scoped to one request |
| `phase` | `"browse"` / `"select"` / `"unknown"` | Discovery funnel stage |
| `category` | `"payments"` | Fixed discovery category, when valid |
| `selected_tool` | `"agentcard"` | Public catalog tool name in the select phase |
| `outcome` | `"success"` / `"failure"` | Attempt result |
| `failure_reason` | `"timeout"` | Allowlisted coarse failure class only |
| `http_status` | `200` | Discovery service response status, if received |
| `latency_ms` | `125` | End-to-end client attempt latency |
| `response_bytes` | `2048` | Response payload size, if received |
| `result_count` | `3` | Number of browse results, or one for selection |
| `query_present` / `reason_present` | `true` / `true` | Presence flags only |
| `custom_endpoint` | `false` | Whether a non-default discovery endpoint was configured |
The query text, selection-reason text, endpoint URL, prompts, transcript, file
paths, and tool setup instructions are **not** included in telemetry. The
backend rejects unknown phase, outcome, and failure labels rather than storing
arbitrary strings.
### Session Start Event
| Field | Example | Purpose |
|-------|---------|----------|
| `id` | `a1b2c3d4-...` | Same random UUID |
| `event` | `"session_start"` | Event type |
| `version` | `"0.6.0"` | jcode version |
| `os` | `"linux"` | Operating system |
| `arch` | `"x86_64"` | CPU architecture |
| `provider_start` | `"OpenAI"` | Provider when session started |
| `model_start` | `"gpt-5.4"` | Model when session started |
| `resumed_session` | `false` | Whether this was a resumed session |
| `session_start_hour_utc` | `13` | Coarse hour-of-day bucket for workflow timing |
| `session_start_weekday_utc` | `2` | Weekday bucket for usage cadence |
| `previous_session_gap_secs` | `3600` | How long since this install's previous session |
| `sessions_started_24h` / `sessions_started_7d` | `3` / `8` | How bursty a user's workflow is recently |
| `active_sessions_at_start` | `2` | Concurrent sessions observed including this one |
| `other_active_sessions_at_start` | `1` | Other sessions already open when this started |
### Session End / Crash Event
| Field | Example | Purpose |
|-------|---------|----------|
| `id` | `a1b2c3d4-...` | Same random UUID |
| `event` | `"session_end"` / `"session_crash"` | Event type |
| `version` | `"0.6.0"` | jcode version |
| `os` | `"linux"` | Operating system |
| `arch` | `"x86_64"` | CPU architecture |
| `provider_start` | `"OpenAI"` | Provider when session started |
| `provider_end` | `"OpenAI"` | Provider when session ended |
| `model_start` | `"gpt-5.4"` | Model when session started |
| `model_end` | `"gpt-5.4"` | Model when session ended |
| `provider_switches` | `0` | How many times you switched providers |
| `model_switches` | `1` | How many times you switched models |
| `duration_mins` | `45` | Session length in minutes |
| `duration_secs` | `2700` | Finer-grained session length |
| `turns` | `23` | Number of user prompts sent |
| `had_user_prompt` | `true` | Whether any real prompt was submitted |
| `had_assistant_response` | `true` | Whether the assistant produced a response |
| `assistant_responses` | `6` | Number of assistant responses |
| `first_assistant_response_ms` | `1200` | Time to first assistant response |
| `first_tool_call_ms` | `900` | Time to first tool invocation |
| `first_tool_success_ms` | `1500` | Time to first successful tool execution |
| `first_file_edit_ms` | `2200` | Time to first successful file edit |
| `first_test_pass_ms` | `4100` | Time to first successful test run |
| `tool_calls` | `8` | Number of tool executions |
| `tool_failures` | `1` | Number of tool execution failures |
| `executed_tool_calls` | `10` | Centralized count of actual registry tool executions |
| `executed_tool_successes` | `9` | Successful registry tool executions |
| `executed_tool_failures` | `1` | Failed registry tool executions |
| `tool_latency_total_ms` | `4200` | Aggregate tool execution latency |
| `tool_latency_max_ms` | `1800` | Slowest single tool call |
| `file_write_calls` | `2` | Count of write/edit/patch style tool uses |
| `tests_run` | `1` | Coarse count of test runs triggered |
| `tests_passed` | `1` | Coarse count of successful test runs |
| `input_tokens` / `output_tokens` | `12345` / `678` | Session-level provider-reported token usage totals |
| `cache_read_input_tokens` / `cache_creation_input_tokens` | `9000` / `1200` | Session-level provider-reported prompt-cache token totals when available |
| `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, selfdev, background, subagents) |
| `tool_cat_*` | `0..N` | Coarse tool category counts (read/search, write, shell, web, memory, subagent, swarm, email, side-panel, goal, MCP, other) |
| `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 |
| `session_success` | `true` | Coarse success proxy based on outcomes like responses, successful tools, tests, or edits |
| `abandoned_before_response` | `false` | Whether the user engaged but got no successful outcome before ending |
| `session_stop_reason` | `"tool_error_loop"` | Coarse inferred pain/churn bucket, such as crash, auth blocked, rate limited, no first response, too slow, tool failures, no useful action, or completed successfully |
| `agent_role` | `"foreground"` / `"subagent"` | Coarse role classification for the session: foreground, background, subagent, or swarm |
| `parent_session_id` | `"session_..."` | Optional parent session ID for attributing spawned/background/subagent work to the initiating session |
| `agent_active_ms_total` | `7200000` | Sum of active agent time across finalized turns; two agents active for two hours count as four agent-hours in aggregate |
| `agent_model_ms_total` / `agent_tool_ms_total` | `5400000` / `1800000` | Approximate active-time split between model/agent thinking and registry tool execution latency |
| `session_idle_ms_total` | `300000` | Time around turns where the session was open but no agent activity was observed |
| `time_to_first_agent_action_ms` | `900` | Time from session start to the first assistant response or tool action |
| `time_to_first_useful_action_ms` | `1500` | Time from session start to the first successful tool/file/test outcome, falling back to first response |
| `spawned_agent_count` | `3` | Count of background, subagent, and swarm task invocations attributed to the session |
| `background_task_count` / `background_task_completed_count` | `1` / `1` | Background work started and successfully completed via background/scheduled tool paths |
| `subagent_task_count` / `subagent_success_count` | `1` / `1` | Subagent task invocations and successful completions |
| `swarm_task_count` / `swarm_success_count` | `1` / `0` | Swarm/agent-coordination task invocations and successful completions |
| `user_cancelled_count` | `1` | Urgent interrupt count, used to detect sessions where the user stopped the agent mid-work |
| `transport_https` | `2` | Number of provider requests sent over HTTPS/SSE |
| `transport_persistent_ws_fresh` | `1` | Number of fresh persistent WebSocket requests |
| `transport_persistent_ws_reuse` | `5` | Number of turns that reused an existing persistent WebSocket |
| `transport_cli_subprocess` | `0` | Number of requests sent through a CLI subprocess transport |
| `transport_native_http2` | `0` | Number of requests sent through native HTTP/2 transports |
| `transport_other` | `0` | Number of requests using any other transport |
| `project_repo_present` | `true` | Whether the working directory looked like a repo |
| `project_lang_*` | `true/false` | Coarse project-language buckets (Rust, JS/TS, Python, Go, Markdown, mixed) |
| `days_since_install` | `12` | Rough install age in days |
| `active_days_7d` / `active_days_30d` | `4` / `9` | How many distinct active days this install had recently |
| `session_start_hour_utc` / `session_end_hour_utc` | `13` / `14` | Session timing buckets for workflow analysis |
| `session_start_weekday_utc` / `session_end_weekday_utc` | `2` / `2` | Weekday timing buckets |
| `previous_session_gap_secs` | `1800` | Time since the previous session on this install |
| `sessions_started_24h` / `sessions_started_7d` | `5` / `12` | Recent session burstiness |
| `active_sessions_at_start` / `other_active_sessions_at_start` | `2` / `1` | Concurrent-session snapshot at session start |
| `max_concurrent_sessions` | `3` | Highest concurrent session count seen during the session |
| `multi_sessioned` | `true` | Whether the user appeared to be running multiple sessions |
| `resumed_session` | `false` | Whether this session was resumed |
| `end_reason` | `"normal_exit"` | Coarse end reason |
| `errors` | `{"provider_timeout": 0, ...}` | Count of errors by category |
### Turn End Event
This is a privacy-safe per-prompt summary event. It contains no prompt text, no response text, and no tool inputs/outputs.
| Field | Example | Purpose |
|-------|---------|----------|
| `event` | `"turn_end"` | Event type |
| `turn_index` | `4` | Which user turn in the session this was |
| `turn_started_ms` | `182000` | Time from session start to turn start |
| `turn_active_duration_ms` | `8200` | Active duration until the last meaningful response/tool activity |
| `idle_before_turn_ms` / `idle_after_turn_ms` | `45000` / `12000` | Workflow pacing around the turn |
| `assistant_responses` | `1` | Responses produced during this turn |
| `first_assistant_response_ms` | `1200` | Time to first response within the turn |
| `first_tool_call_ms` / `first_tool_success_ms` | `900` / `1500` | Tool timing within the turn |
| `first_file_edit_ms` / `first_test_pass_ms` | `2200` / `4100` | Useful outcome timing within the turn |
| `tool_calls` / `tool_failures` | `3` / `1` | Coarse tool activity within the turn |
| `executed_tool_calls` / `executed_tool_successes` / `executed_tool_failures` | `4` / `3` / `1` | Registry tool execution outcomes |
| `tool_latency_total_ms` / `tool_latency_max_ms` | `2600` / `1400` | Tool latency footprint within the turn |
| `file_write_calls` / `tests_run` / `tests_passed` | `1` / `1` / `1` | Outcome proxies for coding workflows |
| `input_tokens` / `output_tokens` | `1200` / `180` | Turn-level provider-reported token usage totals |
| `cache_read_input_tokens` / `cache_creation_input_tokens` | `8000` / `600` | Turn-level provider-reported prompt-cache token totals when available |
| `total_tokens` | `9980` | Sum of input, output, cache-read, and cache-creation tokens for the turn |
| `feature_*_used` | `true/false` | Which feature families were touched in the turn |
| `tool_cat_*` | `0..N` | Tool category mix for the turn |
| `workflow_*_used` | `true/false` | What kind of workflow this turn looked like |
| `turn_success` | `true` | Whether the turn produced a useful response/outcome |
| `turn_abandoned` | `false` | Whether the turn appears to have ended without success |
| `turn_end_reason` | `"next_user_prompt"` | Why the turn was finalized |
### Shared Event Metadata
Most events also carry a few coarse quality / cleanup fields:
| Field | Example | Purpose |
|-------|---------|----------|
| `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 |
| `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 |
## What We Do NOT Collect
- 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 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
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 It Works
1. On first launch, jcode generates a random UUID and sends an `install` event
2. When a session begins, jcode sends a `session_start` event
3. When a session ends normally, jcode sends a `session_end` event with coarse session metrics
4. When auth succeeds, jcode sends a coarse `auth_success` event for activation-funnel analysis
5. When jcode detects a version change, it sends an `upgrade` event
6. On best-effort crash/signal handling, jcode sends a `session_crash` event
7. jcode may also send one-off onboarding milestone events and explicit feedback events when triggered
8. Requests are fire-and-forget HTTP POSTs that don't block normal usage (install/session shutdown have short bounded blocking timeouts)
9. If a request fails (offline, firewall, etc.), jcode silently continues - no retries, no queuing
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/).
### 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.
## How to Opt Out
Any of these methods will disable telemetry completely:
```bash
# Option 1: Environment variable
export JCODE_NO_TELEMETRY=1
# Option 2: Standard DO_NOT_TRACK (https://consoledonottrack.com/)
export DO_NOT_TRACK=1
# Option 3: File-based opt-out
touch ~/.jcode/no_telemetry
```
When opted out, zero network requests are made. The telemetry module short-circuits immediately.
## 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.
## 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.
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.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+393
View File
@@ -0,0 +1,393 @@
[
{
"t": 1500,
"event": "user_message",
"text": "make me a simple flowchart. "
},
{
"t": 8383,
"event": "thinking",
"duration": 800
},
{
"t": 10022,
"event": "stream_text",
"text": "\n\n```mermaid\nflowchart TD\n A[Start] --> B{Decision?}\n B -->|Yes| C[Do Something]\n B -->|No| D[Do Something Else]\n C --> E[Process Result]\n D --> E\n E --> F{Another Check?}\n F -->|Yes| B\n F -->|No| G[End]\n```",
"speed": 80
},
{
"t": 11500,
"event": "token_usage",
"input": 447,
"output": 96,
"cache_read": 6487,
"cache_creation": 8560
},
{
"t": 11500,
"event": "done"
},
{
"t": 14500,
"event": "user_message",
"text": "epxlore this codebase, and then make just a simple diagram of the memory arhcitecutre"
},
{
"t": 18318,
"event": "thinking",
"duration": 800
},
{
"t": 18931,
"event": "stream_text",
"text": "\n\nLet me explore the memory-related code in this codebase first.",
"speed": 80
},
{
"t": 19085,
"event": "token_usage",
"input": 37,
"output": 156,
"cache_read": 15047,
"cache_creation": 541
},
{
"t": 19085,
"event": "tool_start",
"name": "batch",
"input": {
"tool_calls": [
{
"parameters": {
"include": "*.rs",
"path": "src/",
"pattern": "mod memory|pub struct Memory|memory"
},
"tool": "file_grep"
},
{
"parameters": {
"path": "src/"
},
"tool": "ls"
},
{
"parameters": {
"pattern": "src/**/*memory*"
},
"tool": "file_glob"
}
]
}
},
{
"t": 19238,
"event": "tool_done",
"name": "batch",
"output": "--- [1] file_grep ---\nFound 100 matches for 'mod memory|pub struct Memory|memory'\n\nprovider/anthropic.rs:\n 1055: // Dynamic content - NOT cached (date, git status, memory)\n\nprovider/openrouter.rs:\n 400: /// In-memory cache\n 612: /// In-memory cache of per-model endpoint data\n 979: // Check in-memory cache first\n 1024: // Update in-memory cache\n 1036: // Clear in-memory cache\n 1058: // Check in-memory cache\n 1111: // Update in-memo...",
"is_error": false
},
{
"t": 22045,
"event": "thinking",
"duration": 800
},
{
"t": 22659,
"event": "token_usage",
"input": 814,
"output": 211,
"cache_read": 15588,
"cache_creation": 188
},
{
"t": 22659,
"event": "tool_start",
"name": "batch",
"input": {
"tool_calls": [
{
"parameters": {
"file_path": "src/memory.rs",
"limit": 120
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/memory_graph.rs",
"limit": 120
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/memory_agent.rs",
"limit": 120
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/tool/memory.rs",
"limit": 120
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/tool/remember.rs",
"limit": 80
},
"tool": "file_read"
}
]
}
},
{
"t": 22812,
"event": "tool_done",
"name": "batch",
"output": "--- [1] file_read ---\n 1\t//! Memory system for cross-session learning\n 2\t//!\n 3\t//! Provides persistent memory that survives across sessions, organized by:\n 4\t//! - Project (per working directory)\n 5\t//! - Global (user-level preferences)\n 6\t//!\n 7\t//! Integrates with the Haiku sidecar for relevance verification and extraction.\n 8\t\n 9\tuse crate::memory_graph::{EdgeKind, MemoryGraph, GRAPH_VERSION};\n 10\tuse crate::sidecar::HaikuSidecar;\n 11\tuse crate::storage;\n ...",
"is_error": false
},
{
"t": 25459,
"event": "thinking",
"duration": 800
},
{
"t": 26072,
"event": "token_usage",
"input": 1840,
"output": 252,
"cache_read": 15776,
"cache_creation": 1025
},
{
"t": 26072,
"event": "tool_start",
"name": "batch",
"input": {
"tool_calls": [
{
"parameters": {
"file_path": "src/memory.rs",
"limit": 200,
"offset": 120
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/memory_graph.rs",
"limit": 150,
"offset": 120
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/memory_agent.rs",
"limit": 150,
"offset": 120
},
"tool": "file_read"
},
{
"parameters": {
"include": "*.rs",
"path": "src/",
"pattern": "pub struct.*Embed|mod embedding|pub fn embed"
},
"tool": "file_grep"
},
{
"parameters": {
"include": "*.rs",
"path": "src/",
"pattern": "HaikuSidecar|mod sidecar"
},
"tool": "file_grep"
}
]
}
},
{
"t": 26225,
"event": "tool_done",
"name": "batch",
"output": "--- [1] file_read ---\n 121\t .map(str::trim)\n 122\t .filter(|line| !line.is_empty())\n 123\t .collect::<Vec<_>>()\n 124\t .join(\"\\n\")\n 125\t .to_lowercase()\n 126\t}\n 127\t\n 128\t/// Take pending memory if available and fresh\n 129\tpub fn take_pending_memory() -> Option<PendingMemory> {\n 130\t if let Ok(mut guard) = PENDING_MEMORY.lock() {\n 131\t if let Some(pending) = guard.take() {\n 132\t if !pending.is_fresh() {\n 133\t ret...",
"is_error": false
},
{
"t": 28363,
"event": "thinking",
"duration": 800
},
{
"t": 28977,
"event": "token_usage",
"input": 1606,
"output": 194,
"cache_read": 16801,
"cache_creation": 2092
},
{
"t": 28977,
"event": "tool_start",
"name": "batch",
"input": {
"tool_calls": [
{
"parameters": {
"file_path": "src/embedding.rs",
"limit": 80
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/sidecar.rs",
"limit": 80
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/memory.rs",
"limit": 200,
"offset": 300
},
"tool": "file_read"
},
{
"parameters": {
"include": "*.rs",
"path": "src/",
"pattern": "struct MemoryManager|impl MemoryManager"
},
"tool": "file_grep"
}
]
}
},
{
"t": 29130,
"event": "tool_done",
"name": "batch",
"output": "--- [1] file_read ---\n 1\t//! Local embedding generation using all-MiniLM-L6-v2\n 2\t//!\n 3\t//! Provides fast, free, consistent embeddings for memory similarity search.\n 4\t//! Uses tract for pure-Rust ONNX inference (no external dependencies).\n 5\t\n 6\tuse anyhow::{Context, Result};\n 7\tuse std::path::PathBuf;\n 8\tuse std::sync::{Arc, Mutex, OnceLock};\n 9\tuse std::time::{Duration, Instant};\n 10\tuse tokenizers::Tokenizer;\n 11\tuse tract_hir::prelude::*;\n 12\tuse tract_o...",
"is_error": false
},
{
"t": 31410,
"event": "thinking",
"duration": 800
},
{
"t": 32024,
"event": "token_usage",
"input": 1190,
"output": 167,
"cache_read": 18893,
"cache_creation": 1800
},
{
"t": 32024,
"event": "tool_start",
"name": "batch",
"input": {
"tool_calls": [
{
"parameters": {
"file_path": "src/memory.rs",
"limit": 150,
"offset": 870
},
"tool": "file_read"
},
{
"parameters": {
"file_path": "src/memory_agent.rs",
"limit": 200,
"offset": 150
},
"tool": "file_read"
},
{
"parameters": {
"include": "*.rs",
"path": "src/",
"pattern": "MemoryStore|struct.*Store"
},
"tool": "file_grep"
}
]
}
},
{
"t": 32177,
"event": "tool_done",
"name": "batch",
"output": "--- [1] file_read ---\n 871\t project_dir: Option<PathBuf>,\n 872\t /// When true, use isolated test storage instead of real memory\n 873\t test_mode: bool,\n 874\t}\n 875\t\n 876\timpl MemoryManager {\n 877\t pub fn new() -> Self {\n 878\t Self {\n 879\t project_dir: None,\n 880\t test_mode: false,\n 881\t }\n 882\t }\n 883\t\n 884\t /// Create a memory manager in test mode (isolated storage)\n 885\t pub fn new_test() -> Self {\n 886\t Self {\n ...",
"is_error": false
},
{
"t": 33733,
"event": "thinking",
"duration": 800
},
{
"t": 34346,
"event": "token_usage",
"input": 1077,
"output": 93,
"cache_read": 20693,
"cache_creation": 1357
},
{
"t": 34346,
"event": "tool_start",
"name": "read",
"input": {
"file_path": "src/memory.rs",
"limit": 80,
"offset": 570
}
},
{
"t": 34500,
"event": "tool_done",
"name": "read",
"output": " 571\t \"entity\" => MemoryCategory::Entity,\n 572\t \"correction\" => MemoryCategory::Correction,\n 573\t other => MemoryCategory::Custom(other.to_string()),\n 574\t })\n 575\t }\n 576\t}\n 577\t\n 578\t#[derive(Debug, Clone, Serialize, Deserialize, Default)]\n 579\tpub struct MemoryStore {\n 580\t pub entries: Vec<MemoryEntry>,\n 581\t #[serde(default)]\n 582\t pub metadata: HashMap<String, String>,\n 583\t}\n 584\t\n 585\timpl MemoryStore {\n 586\t pub f...",
"is_error": false
},
{
"t": 37500,
"event": "user_message",
"text": "a very simplifed version of it "
},
{
"t": 47652,
"event": "thinking",
"duration": 800
},
{
"t": 48497,
"event": "stream_text",
"text": "\n\n```mermaid\nflowchart TD\n A[\"\ud83e\udde0 Memory Tool\\n(agent-facing API)\"]\n B[\"\ud83d\udcdd Remember Tool\\n(simple notes)\"]\n\n A --> C[\"MemoryManager\\n(project + global scope)\"]\n B --> D[\"NoteStore\\n(JSON file per project)\"]\n\n C --> E[\"MemoryStore\\n(entries, categories, tags)\"]\n C --> F[\"MemoryGraph\\n(edges, clusters, BFS traversal)\"]\n\n E --> G[\"\ud83d\udcbe JSON Files\\n~/.jcode/memory/\"]\n F --> G\n\n H[\"\ud83e\udd16 Memory Agent\\n(background async)\"] --> C\n H --> I[\"Embedder\\n(all-MiniLM-L6-v2, local ONNX)\"]\n H --> J[\"Haiku Sidecar\\n(relevance checks via LLM)\"]\n\n I -->|similarity search| F\n J -->|decide what's relevant| K[\"PENDING_MEMORY\\n(injected into system prompt)\"]\n\n K -->|surfaces memories| L[\"Main Agent\"]\n L -->|context updates| H\n\n style A fill:#4a9eff,color:#fff\n style B fill:#4a9eff,color:#fff\n style H fill:#ff9f43,color:#fff\n style G fill:#2ecc71,color:#fff\n style L fill:#9b59b6,color:#fff\n```\n\nThe main agent talks to the **Memory Agent** in the background. The memory agent uses **local embeddings** for similarity search and a **Haiku sidecar** (cheap LLM) to judge relevance, then injects relevant memories into the system prompt for the main agent to see.",
"speed": 80
},
{
"t": 52500,
"event": "token_usage",
"input": 1115,
"output": 427,
"cache_read": 22050,
"cache_creation": 1170
},
{
"t": 52500,
"event": "done"
}
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+253
View File
@@ -0,0 +1,253 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f4b860" />
<title>The Great Pelican Ride</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@400;600;700&display=swap');
:root { --ink:#152e38; --cream:#fff5dc; --coral:#e75b3b; --gold:#f6b83f; --sea:#2f8f98; }
* { box-sizing:border-box; }
html, body { margin:0; width:100%; height:100%; overflow:hidden; background:#f6bf67; }
body { font-family:Outfit, sans-serif; color:var(--ink); }
button { font:inherit; }
#stage { position:relative; width:100%; height:100%; isolation:isolate; cursor:crosshair; overflow:hidden; background:#f8c66a; }
#scene { width:100%; height:100%; display:block; }
.grain { position:absolute; inset:0; pointer-events:none; opacity:.24; z-index:8; mix-blend-mode:soft-light;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E"); }
.masthead { position:absolute; z-index:10; top:clamp(20px,4vw,52px); left:clamp(22px,5vw,76px); pointer-events:none; }
.eyebrow { display:flex; align-items:center; gap:12px; text-transform:uppercase; letter-spacing:.24em; font-size:clamp(9px,1vw,12px); font-weight:700; }
.eyebrow:before { content:""; width:38px; height:2px; background:currentColor; }
h1 { margin:8px 0 0; font-family:"DM Serif Display",serif; font-weight:400; font-size:clamp(42px,7.8vw,114px); line-height:.79; letter-spacing:-.04em; text-shadow:0 3px 0 rgba(255,245,220,.4); }
h1 em { display:block; margin-left:clamp(40px,9vw,150px); color:var(--cream); font-weight:400; }
.corner-note { position:absolute; right:clamp(18px,4vw,56px); bottom:clamp(19px,4vw,44px); z-index:10; text-align:right; color:var(--cream); text-transform:uppercase; letter-spacing:.14em; font-weight:700; font-size:10px; line-height:1.7; pointer-events:none; }
.controls { position:absolute; z-index:12; right:clamp(18px,4vw,56px); top:clamp(20px,4vw,48px); display:flex; gap:9px; }
.control { color:var(--ink); background:rgba(255,245,220,.64); border:1.5px solid rgba(21,46,56,.45); backdrop-filter:blur(10px); border-radius:999px; padding:10px 15px; font-weight:700; font-size:11px; letter-spacing:.08em; text-transform:uppercase; cursor:pointer; transition:.2s ease; box-shadow:0 8px 30px rgba(21,46,56,.09); }
.control:hover { transform:translateY(-2px); background:var(--cream); box-shadow:0 10px 30px rgba(21,46,56,.18); }
.control:focus-visible { outline:3px solid #fff; outline-offset:3px; }
.weather { position:absolute; left:clamp(20px,4vw,56px); bottom:clamp(18px,4vw,42px); z-index:10; display:flex; align-items:center; gap:12px; font-size:11px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
.windlines { display:flex; gap:4px; align-items:center; }
.windlines i { display:block; height:2px; width:18px; background:var(--ink); animation:gust 1.5s ease-in-out infinite; }
.windlines i:nth-child(2){ width:10px; animation-delay:-.4s }.windlines i:nth-child(3){ width:25px; animation-delay:-.8s }
@keyframes gust { 50% { transform:translateX(7px); opacity:.35; } }
#sky, #sunCore, #seaFill, #roadFill, #nightGlow { transition:fill 1.8s ease, opacity 1.8s ease; }
#nightGlow { opacity:0; }
#stage.night #nightGlow { opacity:1; }
#stage.night .masthead, #stage.night .weather { color:#fff4dc; }
#stage.night .windlines i { background:#fff4dc; }
.paused .windlines i { animation-play-state:paused; }
@media (max-width:700px) { .corner-note { display:none } .control { padding:9px 12px } .masthead { top:76px } h1{font-size:clamp(44px,15vw,76px)} .weather{font-size:9px} }
@media (prefers-reduced-motion:reduce) { .windlines i{animation:none} }
</style>
</head>
<body>
<main id="stage" aria-label="Animated illustration of a pelican riding a bicycle by the sea">
<svg id="scene" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc" preserveAspectRatio="xMidYMid slice">
<title id="title">The Great Pelican Ride</title>
<desc id="desc">A joyful pelican pedals a coral bicycle along a sunlit coastal road while clouds, sea, and birds drift past.</desc>
<defs>
<linearGradient id="skyDay" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#f7b55b"/><stop offset=".66" stop-color="#f8d88d"/><stop offset="1" stop-color="#f9e6b2"/></linearGradient>
<linearGradient id="skyNight" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#132941"/><stop offset=".7" stop-color="#31516a"/><stop offset="1" stop-color="#a76863"/></linearGradient>
<linearGradient id="seaDay" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#65bec1"/><stop offset="1" stop-color="#237681"/></linearGradient>
<linearGradient id="seaNight" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#355b70"/><stop offset="1" stop-color="#152e43"/></linearGradient>
<radialGradient id="sunHalo"><stop stop-color="#fffbdc" stop-opacity=".8"/><stop offset=".36" stop-color="#fff3b2" stop-opacity=".3"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>
<linearGradient id="beak" x1="0" x2="1"><stop stop-color="#ef7543"/><stop offset=".7" stop-color="#f4a24a"/><stop offset="1" stop-color="#de5238"/></linearGradient>
<linearGradient id="feather" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fffdf0"/><stop offset="1" stop-color="#e9dfc6"/></linearGradient>
<filter id="blur"><feGaussianBlur stdDeviation="22"/></filter>
<filter id="softShadow" x="-30%" y="-30%" width="160%" height="180%"><feGaussianBlur in="SourceAlpha" stdDeviation="9"/><feOffset dy="12"/><feColorMatrix values="0 0 0 0 0.05 0 0 0 0 0.15 0 0 0 0 0.18 0 0 0 .28 0"/><feBlend in="SourceGraphic"/></filter>
<clipPath id="seaClip"><path d="M0 470 Q380 450 760 478 T1600 460 V720 H0Z"/></clipPath>
</defs>
<rect id="sky" width="1600" height="900" fill="url(#skyDay)"/>
<rect id="nightGlow" width="1600" height="900" fill="url(#skyNight)"/>
<g id="celestial" transform="translate(1275 172)">
<circle r="170" fill="url(#sunHalo)"/>
<circle id="sunCore" r="67" fill="#fff4b2"/>
<circle id="moonShade" cx="28" cy="-16" r="59" fill="#223c56" opacity="0" style="transition:opacity 1.8s ease"/>
</g>
<g id="stars" fill="#fff7d8" opacity="0" style="transition:opacity 1.8s ease">
<circle cx="1040" cy="110" r="2"/><circle cx="1160" cy="265" r="2.5"/><circle cx="1375" cy="310" r="1.7"/><circle cx="930" cy="220" r="1.5"/><circle cx="1490" cy="120" r="2"/><circle cx="720" cy="100" r="2"/><circle cx="510" cy="200" r="1.5"/><path d="M860 150v12m-6-6h12M1430 220v14m-7-7h14" stroke="#fff7d8" stroke-width="2"/>
</g>
<g id="cloudLayer" fill="#fff7df" opacity=".68">
<g class="cloud" transform="translate(110 250)"><ellipse rx="92" ry="24"/><circle cx="-45" cy="-18" r="35"/><circle cx="4" cy="-31" r="49"/><circle cx="57" cy="-13" r="31"/></g>
<g class="cloud" transform="translate(815 315) scale(.7)"><ellipse rx="110" ry="25"/><circle cx="-60" cy="-21" r="39"/><circle cx="-5" cy="-38" r="54"/><circle cx="55" cy="-16" r="36"/></g>
<g class="cloud" transform="translate(1490 265) scale(.55)"><ellipse rx="110" ry="25"/><circle cx="-60" cy="-21" r="39"/><circle cx="-5" cy="-38" r="54"/><circle cx="55" cy="-16" r="36"/></g>
</g>
<path d="M0 470 Q380 450 760 478 T1600 460 V720 H0Z" id="seaFill" fill="url(#seaDay)"/>
<g id="waves" clip-path="url(#seaClip)" fill="none" stroke="#d7f0df" stroke-linecap="round" opacity=".72">
<path d="M-60 520q60-18 120 0t120 0t120 0t120 0t120 0t120 0t120 0t120 0t120 0t120 0t120 0t120 0" stroke-width="5"/>
<path d="M-20 575q40-14 80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0t80 0" stroke-width="3" opacity=".55"/>
<path d="M-80 640q75-20 150 0t150 0t150 0t150 0t150 0t150 0t150 0t150 0t150 0t150 0t150 0t150 0" stroke-width="6" opacity=".4"/>
</g>
<path d="M0 619Q210 571 415 624T830 621T1235 610T1600 606V900H0Z" id="roadFill" fill="#e8c07d"/>
<path d="M0 649Q210 601 415 654T830 651T1235 640T1600 636" fill="none" stroke="#fff0c9" stroke-width="9" opacity=".88"/>
<g id="roadMarks" fill="#fff0c9" opacity=".82"><path d="M-60 814h180l-22 14H-85z"/><path d="M270 802h180l-16 13H250z"/><path d="M600 792h180l-10 12H585z"/><path d="M930 787h180l-5 11H918z"/><path d="M1260 780h180l2 11h-192z"/><path d="M1570 777h180v12h-188z"/></g>
<g id="foreground" fill="#163d43">
<path d="M0 700q30-90 60 0q25-120 53 0q30-75 64 0v200H0z" opacity=".18"/>
<path d="M1450 700q24-95 52 0q30-130 58 0q25-76 55 0v200h-165z" opacity=".22"/>
</g>
<ellipse id="bikeShadow" cx="920" cy="811" rx="302" ry="34" fill="#173b42" opacity=".18" filter="url(#blur)"/>
<g id="rider" filter="url(#softShadow)">
<!-- bicycle -->
<g id="bike">
<g class="wheel" data-cx="728" data-cy="729"><circle cx="728" cy="729" r="134" fill="none" stroke="#18373d" stroke-width="13"/><circle cx="728" cy="729" r="116" fill="none" stroke="#f8e8bd" stroke-width="3"/><g class="spokes" stroke="#284a4d" stroke-width="3"><path d="M728 613v232M612 729h232M646 647l164 164M810 647L646 811"/></g><circle cx="728" cy="729" r="11" fill="#f5b63e" stroke="#18373d" stroke-width="5"/></g>
<g class="wheel" data-cx="1114" data-cy="729"><circle cx="1114" cy="729" r="134" fill="none" stroke="#18373d" stroke-width="13"/><circle cx="1114" cy="729" r="116" fill="none" stroke="#f8e8bd" stroke-width="3"/><g class="spokes" stroke="#284a4d" stroke-width="3"><path d="M1114 613v232M998 729h232M1032 647l164 164M1196 647l-164 164"/></g><circle cx="1114" cy="729" r="11" fill="#f5b63e" stroke="#18373d" stroke-width="5"/></g>
<g fill="none" stroke="#e65337" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"><path d="M728 729l129-201 107 201H728l89-126 147 126 150-1-111-226"/><path d="M817 603h-52"/></g>
<path d="M968 493h70" stroke="#18373d" stroke-width="12" stroke-linecap="round"/><path d="M1088 507l-38-9" stroke="#18373d" stroke-width="10" stroke-linecap="round"/>
<circle id="crank" cx="964" cy="729" r="25" fill="#f5b63e" stroke="#18373d" stroke-width="7"/>
<path id="pedalArm" d="M964 729l48 0" stroke="#18373d" stroke-width="8" stroke-linecap="round"/>
</g>
<!-- pelican body -->
<g id="pelican">
<path d="M770 497q-72 7-95-58q-24-70 25-132q60-77 175-36q91 33 95 137q4 95-73 127q-67 27-127-38z" fill="url(#feather)" stroke="#18373d" stroke-width="8"/>
<path d="M755 485q-68-32-61-112q4-46 45-76q-24 75 38 126q45 37 112 19q-55 88-134 43z" fill="#f1e7cf" opacity=".75"/>
<!-- wing -->
<path id="wing" d="M760 344q-77 30-60 113q20 97 135 45q-54-15-53-75q3-51 62-65q-44-42-84-18z" fill="#fffdf1" stroke="#18373d" stroke-width="8" stroke-linejoin="round"/>
<path d="M716 430q34 38 104 38M723 394q42 39 99 33" fill="none" stroke="#d7cab0" stroke-width="5" stroke-linecap="round"/>
<!-- neck -->
<path d="M841 352q31-48 21-104q-9-51 23-94q36-48 97-28q50 16 52 65q1 29-17 57q-18 29-16 94l-7 105q-48 7-96-17q-14-83-57-78z" fill="url(#feather)" stroke="#18373d" stroke-width="8"/>
<!-- crest -->
<path d="M875 162q-45-31-71 1q37-3 56 22M883 144q-29-44-67-22q34 9 50 38" fill="none" stroke="#fffdf1" stroke-width="15" stroke-linecap="round"/>
<!-- eye -->
<ellipse cx="963" cy="177" rx="19" ry="22" fill="#f5b63e" stroke="#18373d" stroke-width="6"/><circle id="pupil" cx="968" cy="179" r="7" fill="#18373d"/>
<!-- beak and pouch -->
<path d="M1006 194q110 4 236 55q22 9-5 19q-134 41-233 6z" fill="url(#beak)" stroke="#18373d" stroke-width="8" stroke-linejoin="round"/>
<path d="M1004 270q101 15 230-5q-44 72-129 83q-67 9-101-18z" fill="#ef8b52" stroke="#18373d" stroke-width="8"/>
<path d="M1042 289q70 23 154-8" fill="none" stroke="#f8b56e" stroke-width="5" stroke-linecap="round" opacity=".8"/>
<!-- scarf -->
<path d="M882 317q60 35 123 16l8 40q-70 22-129-16z" fill="#e65337" stroke="#18373d" stroke-width="7"/>
<path id="scarfTail" d="M901 352q-68 10-125-36q35 61 93 76q-69 16-119-5q72 62 153 24z" fill="#e65337" stroke="#18373d" stroke-width="7" stroke-linejoin="round"/>
<!-- feet / legs -->
<g fill="none" stroke="#e88a43" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"><path id="legBack" d="M858 498Q902 575 964 729Q985 752 1012 729"/><path id="legFront" d="M901 512Q954 590 964 729Q943 752 917 729"/></g>
<g stroke="#18373d" stroke-width="7" stroke-linecap="round"><path id="footBack" d="M990 738h43"/><path id="footFront" d="M896 738h43"/></g>
</g>
</g>
<g id="birds" fill="none" stroke="#173d43" stroke-width="5" stroke-linecap="round">
<path d="M250 350q18-18 36 0q18-18 36 0"/><path d="M370 405q12-12 24 0q12-12 24 0"/><path d="M1350 385q16-16 32 0q16-16 32 0"/>
</g>
</svg>
<header class="masthead"><div class="eyebrow">An ocean road story</div><h1>The Great<em>Pelican Ride</em></h1></header>
<nav class="controls" aria-label="Animation controls"><button class="control" id="pauseBtn" type="button">Pause</button><button class="control" id="lightBtn" type="button">Moonlight</button></nav>
<div class="weather"><span class="windlines" aria-hidden="true"><i></i><i></i><i></i></span><span>West wind · perfect riding</span></div>
<div class="corner-note">Somewhere past the last lighthouse<br/>keep your beak to the breeze</div>
<div class="grain"></div>
</main>
<script>
(() => {
const stage = document.querySelector('#stage');
const pauseBtn = document.querySelector('#pauseBtn');
const lightBtn = document.querySelector('#lightBtn');
const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const wheelSpokes = [...document.querySelectorAll('.spokes')];
const clouds = [...document.querySelectorAll('.cloud')];
const roadMarks = document.querySelector('#roadMarks');
const waves = document.querySelector('#waves');
const birds = document.querySelector('#birds');
const pelican = document.querySelector('#pelican');
const rider = document.querySelector('#rider');
const pupil = document.querySelector('#pupil');
const wing = document.querySelector('#wing');
const scarf = document.querySelector('#scarfTail');
const pedalArm = document.querySelector('#pedalArm');
const legBack = document.querySelector('#legBack');
const legFront = document.querySelector('#legFront');
const footBack = document.querySelector('#footBack');
const footFront = document.querySelector('#footFront');
const sky = document.querySelector('#sky');
const sea = document.querySelector('#seaFill');
const road = document.querySelector('#roadFill');
const moonShade = document.querySelector('#moonShade');
const stars = document.querySelector('#stars');
let paused = prefersReduced;
let night = false;
let last = performance.now();
let time = 0;
let pointerX = 0, pointerY = 0, targetX = 0, targetY = 0;
if (paused) { pauseBtn.textContent = 'Play'; stage.classList.add('paused'); }
stage.addEventListener('pointermove', e => {
targetX = (e.clientX / innerWidth - .5) * 2;
targetY = (e.clientY / innerHeight - .5) * 2;
});
stage.addEventListener('pointerleave', () => targetX = targetY = 0);
pauseBtn.addEventListener('click', () => {
paused = !paused;
pauseBtn.textContent = paused ? 'Play' : 'Pause';
stage.classList.toggle('paused', paused);
last = performance.now();
});
lightBtn.addEventListener('click', () => {
night = !night;
stage.classList.toggle('night', night);
lightBtn.textContent = night ? 'Sunrise' : 'Moonlight';
sky.setAttribute('fill', night ? 'url(#skyNight)' : 'url(#skyDay)');
sea.setAttribute('fill', night ? 'url(#seaNight)' : 'url(#seaDay)');
road.setAttribute('fill', night ? '#8b6f67' : '#e8c07d');
moonShade.style.opacity = night ? '.92' : '0';
stars.style.opacity = night ? '.85' : '0';
});
const point = (cx, cy, radius, angle) => ({ x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius });
const legPath = (hipX, hipY, foot, bend) => {
const kneeX = (hipX + foot.x) / 2 + bend;
const kneeY = (hipY + foot.y) / 2 - 8;
return `M${hipX} ${hipY} Q${kneeX.toFixed(1)} ${kneeY.toFixed(1)} ${foot.x.toFixed(1)} ${foot.y.toFixed(1)}`;
};
function frame(now) {
const dt = Math.min((now - last) / 1000, .05); last = now;
if (!paused) time += dt;
pointerX += (targetX - pointerX) * .035;
pointerY += (targetY - pointerY) * .035;
const speed = time * 2.35;
const wheelAngle = speed * 57.2958;
wheelSpokes.forEach((s, i) => {
const wheel = s.closest('.wheel');
s.setAttribute('transform', `rotate(${wheelAngle} ${wheel.dataset.cx} ${wheel.dataset.cy})`);
});
roadMarks.setAttribute('transform', `translate(${-(speed * 150 % 330)} 0)`);
waves.setAttribute('transform', `translate(${-(speed * 18 % 160)} ${Math.sin(time * .8) * 3})`);
clouds.forEach((c, i) => {
const layout = cloudLayouts[i];
const drift = (time * (9 + i * 4) + layout.offset) % 1900;
c.setAttribute('transform', `translate(${drift - 180} ${layout.y}) scale(${layout.scale})`);
});
birds.setAttribute('transform', `translate(${-(time * 13 % 120)} ${Math.sin(time * .7) * 7})`);
const bob = Math.sin(speed * 2) * 4;
rider.setAttribute('transform', `translate(${pointerX * 7} ${bob + pointerY * 3})`);
pelican.style.transformOrigin = '870px 450px';
pelican.style.transform = `rotate(${Math.sin(speed) * 1.2}deg)`;
wing.style.transformOrigin = '780px 405px';
wing.style.transform = `rotate(${Math.sin(time * 2.6) * 2.5}deg)`;
scarf.style.transformOrigin = '900px 355px';
scarf.style.transform = `rotate(${Math.sin(time * 5) * 3 + pointerX * 2}deg)`;
pupil.setAttribute('transform', `translate(${pointerX * 3} ${pointerY * 2})`);
const a = speed * 2.05;
const p1 = point(964, 729, 49, a);
const p2 = point(964, 729, 49, a + Math.PI);
pedalArm.setAttribute('d', `M964 729L${p1.x.toFixed(1)} ${p1.y.toFixed(1)}M964 729L${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`);
legBack.setAttribute('d', legPath(858, 498, p1, -25));
legFront.setAttribute('d', legPath(901, 512, p2, 27));
footBack.setAttribute('d', `M${(p1.x-15).toFixed(1)} ${(p1.y+9).toFixed(1)}h43`);
footFront.setAttribute('d', `M${(p2.x-15).toFixed(1)} ${(p2.y+9).toFixed(1)}h43`);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 MiB

+56
View File
@@ -0,0 +1,56 @@
# Changelog entries
User-facing changelog entries for jcode releases. One JSON file per release,
written by the agent during `/cut-release`, reviewed in the release diff, and
consumed by jcode.sh and the GitHub release body.
## Files
- `v<version>.json` - one entry per release (e.g. `v0.34.0.json`).
- `index.json` - newest-first list of released versions with dates, so
consumers can discover entries without directory listings.
## Entry schema
```json
{
"version": "0.34.0",
"date": "2026-07-02",
"title": "Optional short release name",
"highlights": [
"One-sentence, user-facing description of the most important change."
],
"improvements": [
"Smaller user-visible improvements."
],
"fixes": [
"Bug fixes described by their user-visible effect."
]
}
```
## index.json schema
```json
{
"entries": [
{ "version": "0.34.0", "date": "2026-07-02" }
]
}
```
## Writing guidelines
- Write for users of jcode, not contributors. Describe the effect, not the
implementation ("swarm agents no longer lose retried commands", not
"close mutation-dedup races").
- Skip internal-only changes entirely: refactors, CI, test-only, code moves.
If a release is purely internal, say so in a single `improvements` item like
"Internal reliability and performance work."
- One sentence per item. No trailing periods needed, but be consistent within
an entry.
- `highlights` is for the 1-3 changes a user would actually notice or care
about. Everything else goes in `improvements` or `fixes`. Omit empty arrays.
- `title` is optional. Use it only when a release has an obvious theme.
- Keep the full commit log as the source of truth; the changelog is a
user-facing layer over it, never a replacement.
+18
View File
@@ -0,0 +1,18 @@
{
"entries": [
{ "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" }
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.34.0",
"date": "2026-07-02",
"highlights": [
"Swarm agents now spawn inline by default with a live gallery viewport, and the focused strip expands into a transcript view with todos",
"New /cut-release command commits, pushes, bumps the version, and publishes a GitHub release in one step",
"New /hotkeys command lists every chord along with your personal usage stats"
],
"improvements": [
"Inline images are now fully clickable and cycle through Fit, Large, and Full expand levels",
"Gmail drafts support file attachments, and reading messages surfaces attachment metadata",
"Session search is significantly faster with SIMD scanning and parallel source reads",
"Provider guardrail and refusal stops are surfaced instead of ending turns silently",
"Plan cards wrap long content instead of truncating at the border",
"Rare or unknown hotkey chords get inline feedback instead of doing nothing"
],
"fixes": [
"Queued follow-up messages are no longer lost across disconnects and reloads",
"Swarm task retries no longer replay stale results or race duplicate mutations",
"Scrolling no longer causes inline images to flicker",
"Corrupt protocol frames are skipped instead of killing the session",
"Clicks in kitty terminals are no longer swallowed by same-cell drag jitter",
"Fixed a startup panic in the info-widget cache and stale widget anchors after resizes"
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"version": "0.35.0",
"date": "2026-07-04",
"highlights": [
"Mermaid diagrams now render inline in chat like images, on by default, with aspect-aware sizing and cached re-rasterization on resize",
"Swarm deep mode grows a live task graph with gate audits and renders it as a live mermaid plan diagram, plus a swarm dock widget with a live agent list",
"After provider guardrail refusals or terminal errors, jcode offers a one-keypress reroute to a fallback model instead of silently ending the turn"
],
"improvements": [
"Anthropic models gain a 'max' reasoning effort rung above xhigh",
"GitHub release notes are now generated from the human-readable changelog",
"Skills installed via Claude Code plugins and marketplaces are picked up automatically",
"MCP servers can be individually disabled with a per-server enabled flag",
"FreeBSD x86_64 release binaries are now shipped",
"/login guides you through provider filtering instead of instant-launching a login",
"Onboarding imports logins with a summary-first flow and a one-time resume picker",
"Session search got faster with incremental per-source indexes, and startup connect bursts use much less CPU",
"Rendered markdown blockquotes show a copy badge",
"New scripts/uninstall.sh performs a clean wipe",
"Swarm agent lifecycle transitions are surfaced as status notices, and swarm activity tails interleave live tool output"
],
"fixes": [
"Rapid Esc presses are no longer swallowed by a stale cancel reset",
"Project-local MCP config loads from the session working directory instead of the server's",
"Reasoning effort picked in the model picker is forwarded correctly in remote sessions",
"Real-world network outage errors are retried as transient instead of failing the turn",
"Stream stall detection now works across all providers, not just one",
"Scrolling no longer flips the scrollbar layout or causes flicker during overscroll",
"Webfetch URLs and search queries survive display compaction",
"Swarm spinners keep animating while the coordinator processes without streaming",
"The pinned diagram pane is capped so it never crushes the transcript",
"/rewind N now matches the numbered transcript entries shown in the TUI",
"Fixed border shear from ambiguous-width glyphs in info widgets"
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.35.1",
"date": "2026-07-04",
"title": "Complete the 0.35.0 release for macOS and FreeBSD",
"highlights": [
"Restores macOS and FreeBSD release binaries, which were missing from 0.35.0 due to a platform-specific build error"
],
"improvements": [
"All 0.35.0 changes apply: inline mermaid diagrams, swarm deep-mode task graphs, and one-keypress model reroute after refusals"
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"version": "0.36.0",
"date": "2026-07-05",
"title": "Image pipeline hardening, big memory cuts, and swarm orchestration upgrades",
"highlights": [
"Inline images are hardened end to end: payloads are released after decode (~8MB back per image-heavy session), evicted cache entries self-heal from disk, and undecodable images can no longer spin the render loop",
"Client memory drops sharply: syntect switches to the onig backend (~17MB), the session-search index is bloom-filtered (-24MB server-side), retained heap is trimmed on idle, and glibc mmap thresholds are pinned so large transients return to the OS",
"Swarm plans render as a live inline mermaid task graph in chat, with per-agent task labels, honest run_plan progress, and per-spawn model/effort selection"
],
"improvements": [
"Swarm: broadcasts and shared context are scoped to the sender's spawned subtree",
"Swarm: tasks stranded on dead workers are salvaged and reported to the coordinator; orphaned children are reparented when a mid-tree member leaves",
"Swarm: run_plan pauses on credential-failure waves and surfaces the fix instead of burning retries",
"Swarm: state persistence moved to a durable directory so it survives reboots",
"Swarm: long messages and reports require a tldr and collapse to it with an expand badge",
"Composed input text is mouse-selectable and copyable (issue #430)",
"Interrupts fan out to all active turn signals so cancel is immediate (issue #428)",
"Fable 5 defaults to low reasoning effort; effort=none now fully suppresses thinking",
"Provider runtimes (Anthropic, OpenAI, OpenRouter, Copilot, Cursor, Gemini, Antigravity) moved out of the base crate, so provider edits rebuild far less",
"Resumed sessions survive corrupt journal lines and keep the last prompt",
"agentgrep fixes common agent failure modes and truncates huge trace lines"
],
"fixes": [
"Scroll no longer hangs when older history is prepended to a live transcript (issue #344)",
"Sidebar context figure stays consistent with compaction accounting (issue #441)",
"Auto provider init registers external runtimes in every entry path, so OpenAI-compatible profile routes always reach the model picker",
"Resume no longer deadlocks when the live-attach target agent is busy",
"Auto-poke continuations no longer render as user prompts after reload",
"Copy badges no longer truncate blockquote content"
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"version": "0.37.0",
"date": "2026-07-07",
"title": "Sponsored tool discovery, inline todo card, and cache-aware notices",
"highlights": [
"New discover_tools capability lets the agent browse and select sponsored third-party tools in two phases (browse a category, then fetch setup instructions), with clear (sponsored discovery) provenance tags, substantive selection reasons, and an opt-out config",
"A live todo card can be pinned inline in chat via /todos or Alt+P, and todos now track confidence history with a spike-aware validation gate",
"Claude Sonnet 5 supports reasoning effort selection and is classified as a native 1M-context model"
],
"improvements": [
"Named provider models from your config now appear in the model picker (issue #444)",
"Endorsed-but-not-installed skills are explained instead of reported as 'Unknown skill' (issue #445)",
"Cold prompt-cache notices are shorter, show how long ago the cache went cold, report effective prompt tokens, and the expiry countdown window scales with the cache TTL",
"Documented harness cache invalidations are attributed in the TUI so cache misses are explainable",
"Swarm plans render as a reliable, legible mermaid task graph and are labeled Plan in the todo widget",
"The initiative tool no longer auto-opens the side panel"
],
"fixes": [
"Explicit /login credentials now win over stale API-key environment variables inherited from the parent shell",
"Setting prevent_sleep_while_streaming=false is honored by the TUI sleep guard",
"Long silent reasoning phases no longer trip the client stall guard (issue #451)",
"OpenRouter: local llama.cpp context windows are read from meta.n_ctx (issue #447), and tool schemas are sanitized for strict OpenAI-compatible endpoints (issue #446)",
"default_provider pointing at an OpenAI-compatible catalog profile is honored (issue #448)",
"Server-path message hashing uses a cache-relevant projection, avoiding false KV-cache misses",
"Mermaid aspect-profile diagrams no longer draw outside their render scope, streaming previews clear on empty-buffer commits, and /clear fully re-scopes active diagrams",
"Inline-image marker text is never drawn to the terminal",
"Rewinding a session mid-stream drops stale streaming state instead of replaying it"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"version": "0.38.0",
"date": "2026-07-09",
"title": "Light terminal theme support and goal-level todo scoring",
"highlights": [
"The TUI detects light terminal backgrounds and adapts its colors automatically, with a config override for manual control",
"Todos now carry goal-level hill-climbability scores with measurable objectives, and the agent is nudged to reframe taste-driven goals honestly instead of inventing proxy metrics"
],
"improvements": [
"New Anthropic models get optimistic reasoning-cap defaults instead of being pinned to conservative limits",
"The todo card hotkey default moved from Alt+P to Alt+X to avoid conflicts"
],
"fixes": [
"Reasoning effort is passed correctly for GPT-family models on OpenAI-compatible gateways",
"Phantom OpenRouter routes that pointed at unavailable models are no longer offered"
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"version": "0.39.0",
"date": "2026-07-09",
"title": "Cursor provider restored, active sessions manager, and cross-desktop launch hotkeys",
"highlights": [
"The Cursor provider works again: jcode now talks to Cursor's current agent service directly instead of a decommissioned endpoint that failed with \"Update Required\" errors",
"New active sessions manager (/active, or Left on an empty prompt when enabled) shows your live sessions with 'working'/'ready' badges so you can see which ones need attention"
],
"improvements": [
"Launch hotkey setup now covers GNOME, KDE Plasma, Cinnamon, MATE, XFCE, bspwm, Hyprland, sway, and i3 on Linux, plus config-driven hotkeys on Windows",
"The active sessions view refreshes live presence every couple of seconds and labels the session you opened it from"
],
"fixes": [
"Cursor chat no longer fails with resource-exhausted / payment-required errors caused by the retired ChatService endpoint"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"version": "0.40.0",
"date": "2026-07-10",
"title": "Parallel browser control, clearer goals, and fresher model sessions",
"highlights": [
"Browser automation sessions can now bind to individual browser windows, allowing parallel agents to work without interfering with each other's tabs",
"Todo widgets now show each goal's hill-climbability score or taste-driven status, making it easier to see whether progress has a measurable objective",
"Remote runtimes can use stable display names, and new server sessions now honor the latest saved provider and model defaults instead of stale server startup settings"
],
"improvements": [
"The model picker can be limited to configured provider families, presents reasoning-effort rows consistently, and uses clearer alignment and login guidance",
"Claude Fable 5 now defaults to high reasoning effort",
"Memory relevance and extraction now default to GPT-5.6 Luna with reasoning disabled for lower latency when OpenAI credentials are available",
"Tool transcript rows now retain useful summaries for scheduling, skills, MCP, discovery, Gmail, browser, and other tools across compaction and session reloads",
"The session picker derives useful names from active todo groups or goal objectives when no manual session title is set",
"Project-local skills are isolated to the workspace where they are defined so sessions in other repositories do not inherit them",
"The iOS app now has a production-ready icon and automated TestFlight delivery"
],
"fixes": [
"OpenAI model catalog refreshes recover automatically when an expired token initially returns 401",
"Revoked or unknown phone connection tokens are rejected during the handshake, and the iOS app stops reconnecting in a loop and prompts for re-pairing",
"New sessions created by a long-running shared server no longer display or use an outdated provider and model selection"
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"version": "0.41.0",
"date": "2026-07-10",
"title": "Inline generated images and Gmail by default",
"highlights": [
"Provider-generated images now render inline beside their transcript entries in the TUI",
"Gmail is now available by default in the full tool profile and can still be hidden with the tools disabled list",
"The new /swarm-prompt command opens the active swarm routing prompt for straightforward project or global customization"
],
"improvements": [
"Kitty image transfers are compressed to reduce terminal rendering overhead",
"Explicit swarm spawns now require short labels so parallel agents are easier to identify"
],
"fixes": [
"Anthropic API connections now honor their selected route and accept Jcode tools whose schemas previously used unsupported top-level combinators",
"Side-panel Mermaid diagrams no longer render duplicate images",
"Long inline image labels stay within one row instead of disrupting transcript layout"
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"version": "0.42.0",
"date": "2026-07-11",
"title": "Richer swarm status and resilient sessions",
"highlights": [
"Inline swarm agent cards now show richer live status, expanded details, and clearer running animations",
"Fuzzy search now tolerates typos so models, sessions, and commands are easier to find",
"Running sessions are now animated in the resume picker and preserved more reliably during refreshes"
],
"improvements": [
"Memory status uses a quieter count-first display and stays hidden when memory is disabled",
"Mermaid rendering now follows one consistent feature toggle across configuration surfaces",
"Skill slash commands can include a prompt after the command instead of requiring a second message"
],
"fixes": [
"Detached and reloaded sessions now recover more reliably without lifecycle races, stale cleanup, or lost follow-up work",
"Model catalogs refresh correctly after login imports and session resumes",
"Provider retries now respect server delay hints, and interrupted OpenAI websocket responses start with a clean chain",
"TUI redraw and retry amplification has been reduced, while remote debug snapshots now capture the current frame"
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"version": "0.43.0",
"date": "2026-07-11",
"title": "Terminal math and smoother swarm work",
"highlights": [
"LaTeX now renders as readable terminal math, including common inline, display, fenced, and environment-based notation",
"Swarm waits now run asynchronously so the coordinator stays responsive while agents finish",
"The CLI can override the working directory used by remote sessions"
],
"improvements": [
"Swarm agent cards appear directly beneath spawn calls with clearer beta labeling",
"Tool discovery covers more integration categories",
"Todo guidance now emphasizes measurable goals and evidence-backed completion confidence"
],
"fixes": [
"Images with mismatched format labels are corrected before sending so providers no longer reject valid image payloads",
"Idle TUI sessions release retained heap memory more effectively",
"Jcode login directs users to the correct pricing path"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"version": "0.44.0",
"date": "2026-07-12",
"title": "More reliable swarms across platforms",
"highlights": [
"Swarm sessions use broader collision-resistant identities and clean up finished terminal members more reliably",
"Windows process handling now terminates detached task trees reliably and avoids startup and named-pipe failures",
"Amazon Bedrock now accepts tools whose top-level JSON Schema uses oneOf, anyOf, or allOf"
],
"improvements": [
"Swarm cards use calmer status indicators, stable worker identities, cleaner layouts, and tighter placement beneath spawn calls",
"Markdown and LaTeX rendering handles more notation forms and malformed input safely",
"Usage displays show reset countdowns, clear stale values correctly, and hide billing details when account authentication is unavailable",
"Project links and website integrations now use jcode.sh"
],
"fixes": [
"Remote server sessions initialize with the client's actual working directory",
"Command previews strip ANSI control sequences before rendering",
"Reload handoffs no longer issue unwanted terminal queries",
"Swarm gallery previews build correctly with current member metadata"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"version": "0.45.0",
"date": "2026-07-13",
"title": "Sharper planning and richer terminal workflows",
"highlights": [
"Image files can be dragged directly into the composer and attach immediately",
"LaTeX can render as terminal images by default, including reliable multiline output",
"Todo and swarm activity now use compact borderless cards, clearer notifications, and denser progress indicators"
],
"improvements": [
"Todo goals now capture concrete feedback loops and enforce stronger measurable quality and end-to-end ownership gates",
"Terminal titles stay synchronized with todo session titles while preserving legacy title fallbacks",
"Startup avoids blocking terminal capability probes",
"Managed phone-server deployment guidance and defaults use stronger AWS access controls"
],
"fixes": [
"Agentgrep outline searches no longer duplicate relative file paths",
"Exact fuzzy-search matches rank correctly",
"Swarm cards resolve correctly outside gallery-filtered views",
"Dropped images bypass slash-command routing and attach without delay"
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"version": "0.46.0",
"date": "2026-07-13",
"title": "Clearer swarm hierarchies and steadier math rendering",
"highlights": [
"Spawn cards now show nested agents and their active work directly in the chat transcript",
"Swarm prompts can be opened from inline controls without leaving the current workflow",
"Multiline LaTeX stays stable while responses stream and standalone equations render as complete math blocks"
],
"improvements": [
"LaTeX images scale to the terminal font size for more consistent visual proportions",
"OpenAI OAuth usage displays adapt to dynamically reported context and rate-limit windows"
],
"fixes": [
"Mouse capture remains enabled after restarting the TUI",
"Task graph seeding rejects collisions safely instead of partially applying conflicting nodes",
"Structured Markdown layouts preserve constrained widths when tables, lists, and blockquotes contain math"
]
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "jcode-agent-runtime"
version = "0.1.0"
edition = "2024"
[lib]
name = "jcode_agent_runtime"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
tokio = { version = "1", features = ["sync"] }
[dev-dependencies]
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "macros"] }
+282
View File
@@ -0,0 +1,282 @@
use std::sync::Arc;
/// A soft interrupt message queued for injection at the next safe point.
#[derive(Debug, Clone)]
pub struct SoftInterruptMessage {
pub content: String,
/// If true, can skip remaining tools when injected at point C.
pub urgent: bool,
pub source: SoftInterruptSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SoftInterruptSource {
User,
System,
BackgroundTask,
}
/// Thread-safe soft interrupt queue that can be accessed without holding the agent lock.
pub type SoftInterruptQueue = Arc<std::sync::Mutex<Vec<SoftInterruptMessage>>>;
/// Signal to move the currently executing tool to background.
/// Uses std::sync so it can be set without async from outside the agent lock.
pub type BackgroundToolSignal = Arc<std::sync::atomic::AtomicBool>;
/// Signal to gracefully stop generation.
pub type GracefulShutdownSignal = Arc<std::sync::atomic::AtomicBool>;
/// Async-aware interrupt signal that combines AtomicBool (sync read) with
/// tokio::Notify (async wake). Eliminates spin-loops during tool execution.
#[derive(Clone)]
pub struct InterruptSignal {
flag: Arc<std::sync::atomic::AtomicBool>,
/// Monotonic fire counter. Lets owners of a timed/deferred reset detect
/// that a *newer* fire landed in the meantime and skip the reset instead
/// of erasing a cancel the target has not observed yet (issue #428).
epoch: Arc<std::sync::atomic::AtomicU64>,
notify: Arc<tokio::sync::Notify>,
}
impl InterruptSignal {
pub fn new() -> Self {
Self {
flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)),
notify: Arc::new(tokio::sync::Notify::new()),
}
}
pub fn fire(&self) {
self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.flag.store(true, std::sync::atomic::Ordering::SeqCst);
self.notify.notify_waiters();
}
pub fn is_set(&self) -> bool {
self.flag.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn reset(&self) {
self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
}
/// Current fire epoch. Capture this right after a [`fire`](Self::fire) to
/// later reset only that specific fire via
/// [`reset_if_epoch`](Self::reset_if_epoch).
pub fn epoch(&self) -> u64 {
self.epoch.load(std::sync::atomic::Ordering::SeqCst)
}
/// Reset the signal only if no newer [`fire`](Self::fire) happened since
/// `epoch` was captured. Returns `true` when the reset was applied.
///
/// If a racing fire lands between the epoch check and the reset, the
/// fire is restored (flag re-set and waiters re-notified) so no cancel
/// is ever silently erased.
pub fn reset_if_epoch(&self, epoch: u64) -> bool {
if self.epoch.load(std::sync::atomic::Ordering::SeqCst) != epoch {
return false;
}
self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
if self.epoch.load(std::sync::atomic::Ordering::SeqCst) != epoch {
// A newer fire raced with the reset; restore it.
self.flag.store(true, std::sync::atomic::Ordering::SeqCst);
self.notify.notify_waiters();
return false;
}
true
}
pub async fn notified(&self) {
let mut notified = std::pin::pin!(self.notify.notified());
// Explicitly register this waiter with the Notify before checking the
// flag. `notify_waiters()` (used by `fire()`) wakes only registered
// waiters; current tokio registers a `notified()` future at creation,
// but `enable()` makes the registration explicit rather than relying
// on that version-specific guarantee, since a lost wakeup here parks
// the cancel path (agent stream loop, tool-wait select) until an
// unrelated event arrives (issue #428).
notified.as_mut().enable();
if self.is_set() {
return;
}
notified.await;
}
pub fn as_atomic(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.flag)
}
/// True when `other` is a clone of this signal (shares the same state).
/// Used by cancel fan-out to avoid double-firing the same signal and by
/// diagnostics that need to detect stale signal instances (issue #428).
pub fn same_instance(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.flag, &other.flag)
}
}
impl Default for InterruptSignal {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct StreamError {
pub message: String,
pub retry_after_secs: Option<u64>,
}
impl StreamError {
pub fn new(message: String, retry_after_secs: Option<u64>) -> Self {
Self {
message,
retry_after_secs,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// Documents the tokio semantics `InterruptSignal::notified()` relies on:
/// current tokio guarantees a `notified()` future receives wakeups from
/// `notify_waiters()` from the moment it is *created*, even before its
/// first poll. The explicit `enable()` in `notified()` makes that
/// registration explicit instead of relying on the version-specific
/// creation-time guarantee (hardening for issue #428).
#[tokio::test]
async fn notified_future_receives_notify_waiters_from_creation() {
let notify = tokio::sync::Notify::new();
// Created before the notification, not yet polled: must be woken.
let created_before = notify.notified();
notify.notify_waiters();
tokio::time::timeout(Duration::from_millis(100), created_before)
.await
.expect("a notified() future created before notify_waiters() must be woken");
// Created after the notification: must NOT be woken (notify_waiters
// stores no permit). This is why fire() also sets the atomic flag.
let created_after = notify.notified();
assert!(
tokio::time::timeout(Duration::from_millis(50), created_after)
.await
.is_err(),
"notify_waiters() must not store a permit for future waiters"
);
}
/// Probabilistic race hammer for issue #428: `fire()` must never be lost
/// regardless of where the waiter is between creating the `notified()`
/// future and its first poll. The agent stream loop recreates this future
/// per stream event, so under fast token streams the pre-fix race made
/// Esc/Ctrl+C cancels appear to be ignored.
#[test]
fn fire_never_loses_wakeup_while_notified_races() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_time()
.build()
.expect("runtime");
rt.block_on(async {
for i in 0..2000 {
let signal = InterruptSignal::new();
let waiter = {
let signal = signal.clone();
tokio::spawn(async move { signal.notified().await })
};
// Fire concurrently: the waiter may be anywhere between
// future creation and first poll.
signal.fire();
tokio::time::timeout(Duration::from_secs(2), waiter)
.await
.unwrap_or_else(|_| {
panic!(
"lost wakeup on iteration {i}: notified() missed fire() (issue #428)"
)
})
.expect("waiter task must not panic");
}
});
}
/// A fire() that happened before notified() is observed immediately.
#[tokio::test]
async fn notified_returns_immediately_when_already_fired() {
let signal = InterruptSignal::new();
signal.fire();
tokio::time::timeout(Duration::from_millis(100), signal.notified())
.await
.expect("pre-fired signal must resolve notified() immediately");
}
/// reset() clears the flag so subsequent notified() calls wait again.
#[tokio::test]
async fn reset_clears_fired_state() {
let signal = InterruptSignal::new();
signal.fire();
assert!(signal.is_set());
signal.reset();
assert!(!signal.is_set());
let waited = tokio::time::timeout(Duration::from_millis(50), signal.notified()).await;
assert!(waited.is_err(), "reset signal must park notified() again");
}
/// reset_if_epoch() clears the flag only for the fire that captured the
/// epoch. A deferred reset (e.g. the server's 500ms timer for detached
/// turns) must not erase a newer cancel fired in the meantime, otherwise
/// rapid repeated Esc presses cancel each other out (issue #428).
#[test]
fn reset_if_epoch_skips_when_newer_fire_landed() {
let signal = InterruptSignal::new();
signal.fire();
let first_epoch = signal.epoch();
// A second cancel (repeated Esc) fires before the deferred reset runs.
signal.fire();
assert!(
!signal.reset_if_epoch(first_epoch),
"stale deferred reset must be skipped"
);
assert!(
signal.is_set(),
"newer cancel must survive the stale deferred reset"
);
// The reset scheduled for the latest fire still works.
let second_epoch = signal.epoch();
assert!(signal.reset_if_epoch(second_epoch));
assert!(!signal.is_set());
// And a reset for an already-consumed epoch stays a no-op.
assert!(!signal.reset_if_epoch(first_epoch));
}
/// A fire() racing the flag-clear inside reset_if_epoch() is restored
/// rather than silently erased.
#[test]
fn reset_if_epoch_never_erases_concurrent_fire() {
for _ in 0..2000 {
let signal = InterruptSignal::new();
signal.fire();
let epoch = signal.epoch();
let firer = {
let signal = signal.clone();
std::thread::spawn(move || signal.fire())
};
let _ = signal.reset_if_epoch(epoch);
firer.join().expect("firer thread");
assert!(
signal.is_set(),
"a concurrent fire() must never be erased by reset_if_epoch()"
);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "jcode-ambient-types"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
+32
View File
@@ -0,0 +1,32 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum UsageSource {
User,
Ambient,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageRecord {
pub timestamp: DateTime<Utc>,
pub source: UsageSource,
pub tokens_input: u32,
pub tokens_output: u32,
pub provider: String,
}
impl UsageRecord {
pub fn total_tokens(&self) -> u64 {
self.tokens_input as u64 + self.tokens_output as u64
}
}
#[derive(Debug, Clone)]
pub struct RateLimitInfo {
pub limit_tokens: Option<u64>,
pub remaining_tokens: Option<u64>,
pub limit_requests: Option<u64>,
pub remaining_requests: Option<u64>,
pub reset_at: Option<DateTime<Utc>>,
}
+141
View File
@@ -0,0 +1,141 @@
[package]
name = "jcode-app-core"
version = "0.1.0"
edition = "2024"
publish = false
# Application core for jcode: all non-presentation modules (server, agent,
# provider, auth, session, tool, config, etc.) extracted out of the monolithic
# root `jcode` crate so they compile as a separate rustc unit. The root `jcode`
# crate (cli + tui + bin) re-exports this crate's modules via `pub use
# jcode_app_core::*`, preserving every existing `crate::<module>` path.
[lib]
name = "jcode_app_core"
path = "src/lib.rs"
[dependencies]
# NOTE: the jemalloc allocator stats live in `jcode-base` (process_memory) and
# local embeddings live in `jcode-base` (embedding); this crate forwards those
# features to jcode-base rather than depending on the backing crates directly.
# Async runtime
tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio-util = { version = "0.7", features = ["rt"] }
futures = "0.3"
async-trait = "0.1"
# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "blocking", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
# CLI
# File operations
glob = "0.3"
similar = "2" # diffing for edits
# Utilities
dirs = "5" # home directory
anyhow = "1"
libc = "0.2" # Unix system calls (flock)
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
urlencoding = "2" # URL encoding for web search
uuid = { version = "1", features = ["v4", "v5"] }
# Embeddings (local inference) live in jcode-base; this crate forwards the
# `embeddings` feature to jcode-base rather than depending on jcode-embedding.
jcode-import-core = { path = "../jcode-import-core" }
# OAuth
base64 = "0.22"
sha2 = "0.10"
rand = "0.9.3"
url = "2"
open = "5" # Open URLs in browser
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" }
# 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.
jcode-update-core = { path = "../jcode-update-core" }
jcode-terminal-image = { path = "../jcode-terminal-image" }
# Streaming
tokio-stream = "0.1"
# TUI
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
# Markdown & syntax highlighting
unicode-width = "0.2" # Unicode character display width
# PDF parsing (behind feature flag)
jcode-pdf = { path = "../jcode-pdf", optional = true }
jcode-build-support = { path = "../jcode-build-support" }
jcode-build-meta = { path = "../jcode-build-meta" }
# Foundational layer (provider, auth, config, session, message, memory, ...).
# 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-core = { path = "../jcode-core" }
jcode-message-types = { path = "../jcode-message-types" }
jcode-overnight-core = { path = "../jcode-overnight-core" }
jcode-plan = { path = "../jcode-plan" }
jcode-swarm-core = { path = "../jcode-swarm-core" }
jcode-selfdev-types = { path = "../jcode-selfdev-types" }
jcode-session-types = { path = "../jcode-session-types" }
jcode-setup-hints = { path = "../jcode-setup-hints" }
jcode-task-types = { path = "../jcode-task-types" }
jcode-tool-core = { path = "../jcode-tool-core" }
jcode-tool-types = { path = "../jcode-tool-types" }
# Archive extraction (for auto-update)
flate2 = "1"
tar = "0.4"
tempfile = "3"
agentgrep = { git = "https://github.com/1jehuang/agentgrep.git", tag = "v0.1.6" }
[features]
default = ["pdf", "embeddings", "bedrock"]
# jemalloc allocator stats live in jcode-base (process_memory); forward there.
jemalloc = ["jcode-base/jemalloc"]
jemalloc-prof = ["jcode-base/jemalloc-prof"]
# local embeddings live in jcode-base (embedding); forward there.
embeddings = ["jcode-base/embeddings"]
# live AWS Bedrock support lives in jcode-base (provider/bedrock); forward there.
bedrock = ["jcode-base/bedrock"]
# 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
# ExternalAuthReviewCandidate read accessors) as `pub` and forwards
# jcode-base's `test-support` so downstream crates' test targets can reach the
# whole stack's helpers. Never enabled in normal (non-test) builds.
test-support = ["jcode-base/test-support"]
[dev-dependencies]
# Used by async streaming tests (ambient/runner_tests, server/client_actions_tests).
async-stream = "0.3"
# Enables jcode-base's test-support helpers (storage::lock_test_env,
# auth::test_sandbox, bus::reset_models_updated_publish_state_for_tests) for
# *this* crate's own test target. Feature unification applies this only to
# test/bench/example builds, not to a normal `cargo build`.
jcode-base = { path = "../jcode-base", features = ["test-support"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
[target.'cfg(target_os = "macos")'.dependencies]
global-hotkey = "0.7"
# Native desktop control for the `macos_computer_use` tool (synthetic mouse/keyboard
# events, cursor positioning, display bounds). `highsierra` enables the
# CGEventCreateScrollWheelEvent2 binding used for scroll.
core-graphics = { version = "0.23", features = ["highsierra"] }
+997
View File
@@ -0,0 +1,997 @@
#![cfg_attr(test, allow(clippy::await_holding_lock))]
mod compaction;
mod environment;
mod inline_tail;
mod interrupts;
mod messages;
mod prompting;
mod provider;
mod response_recovery;
mod status;
mod streaming;
mod tools;
mod turn_execution;
mod turn_loops;
mod turn_streaming_mpsc;
mod utils;
use self::streaming::{send_stream_keepalive_mpsc, stream_keepalive_ticker};
use self::tools::{
cap_sdk_tool_content_for_history, cap_tool_output_for_history, print_tool_summary,
tool_output_side_pane_images, tool_output_to_content_blocks,
};
use self::utils::trace_enabled;
use crate::build;
use crate::bus::{Bus, BusEvent, SubagentStatus, ToolEvent, ToolStatus};
use crate::cache_tracker::CacheTracker;
use crate::compaction::CompactionEvent;
use crate::id;
use crate::logging;
use crate::message::{
ContentBlock, Message, Role, StreamEvent, TOOL_OUTPUT_MISSING_TEXT, ToolCall, ToolDefinition,
};
use crate::protocol::{HistoryMessage, ServerEvent};
use crate::provider::{NativeToolResult, Provider, ProviderRuntimeState};
use crate::session::{GitState, Session, SessionStatus, StoredDisplayRole, StoredMessage};
use crate::skill::SkillRegistry;
use crate::tool::{Registry, ToolContext, ToolExecutionMode};
use anyhow::Result;
use futures::StreamExt;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use interrupts::{NoToolCallOutcome, PostToolInterruptOutcome};
pub use jcode_agent_runtime::{
BackgroundToolSignal, GracefulShutdownSignal, InterruptSignal, SoftInterruptMessage,
SoftInterruptQueue, SoftInterruptSource, StreamError,
};
const JCODE_NATIVE_TOOLS: &[&str] = &["selfdev", "communicate"];
static RECOVERED_TEXT_WRAPPED_TOOL_CALLS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
static JCODE_REPO_SOURCE_STATE: LazyLock<(Option<String>, Option<bool>)> = LazyLock::new(|| {
crate::build::get_repo_dir()
.map(|repo_dir| {
(
build::current_git_hash(&repo_dir).ok(),
build::is_working_tree_dirty(&repo_dir).ok(),
)
})
.unwrap_or((None, None))
});
static WORKING_GIT_STATE_CACHE: LazyLock<StdMutex<HashMap<PathBuf, Option<GitState>>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
const STREAM_KEEPALIVE_PONG_ID: u64 = 0;
fn stable_hash_str(value: &str) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
fn stable_hash_json<T: serde::Serialize + ?Sized>(value: &T) -> u64 {
let encoded = serde_json::to_string(value).unwrap_or_default();
stable_hash_str(&encoded)
}
fn stable_json_len<T: serde::Serialize + ?Sized>(value: &T) -> usize {
serde_json::to_string(value)
.map(|encoded| encoded.len())
.unwrap_or_default()
}
fn message_hashes(messages: &[Message]) -> Vec<u64> {
// Hash the cache-relevant projection, not the raw Message. Raw hashing
// keys off non-transmitted metadata (timestamp, tool_duration_ms,
// ReasoningTrace blocks, cache_control markers), which triggers spurious
// harness:_prefix_changed KV-cache miss reports when the same message is
// re-serialized with backfilled metadata on the next turn.
crate::message::cache_relevant_message_hashes(messages)
}
fn kv_cache_request_event(
messages: &[Message],
tools: &[ToolDefinition],
system_static: &str,
ephemeral_messages: &[Message],
) -> ServerEvent {
let ephemeral_hash = if ephemeral_messages.is_empty() {
None
} else {
Some(stable_hash_json(ephemeral_messages))
};
ServerEvent::KvCacheRequest {
system_static_hash: stable_hash_str(system_static),
tools_hash: stable_hash_json(tools),
messages_hash: stable_hash_json(&crate::message::cache_relevant_messages(messages)),
message_hashes: message_hashes(messages),
message_count: messages.len(),
tool_count: tools.len(),
system_static_chars: system_static.chars().count(),
tools_json_chars: stable_json_len(tools),
messages_json_chars: stable_json_len(messages),
ephemeral_hash,
ephemeral_chars: stable_json_len(ephemeral_messages),
ephemeral_message_count: ephemeral_messages.len(),
}
}
fn log_agent_provider_stream_lifecycle(
level: logging::LogLevel,
agent: &Agent,
phase: &str,
api_start: Instant,
fields: Vec<(&str, String)>,
) {
let mut owned = vec![
("phase".to_string(), phase.to_string()),
("provider".to_string(), agent.provider.name().to_string()),
("model".to_string(), agent.provider.model()),
("session_id".to_string(), agent.session.id.clone()),
(
"provider_session_id".to_string(),
agent
.provider_session_id
.clone()
.unwrap_or_else(|| "none".to_string()),
),
(
"connection_type".to_string(),
agent
.last_connection_type
.clone()
.unwrap_or_else(|| "unknown".to_string()),
),
(
"elapsed_ms".to_string(),
api_start.elapsed().as_millis().to_string(),
),
];
owned.extend(
fields
.into_iter()
.map(|(key, value)| (key.to_string(), value)),
);
logging::event(level, "AGENT_PROVIDER_STREAM_LIFECYCLE", owned);
}
/// Token usage from the last API request
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
}
#[derive(Debug, Clone)]
struct RewindUndoSnapshot {
messages: Vec<StoredMessage>,
provider_session_id: Option<String>,
session_provider_session_id: Option<String>,
visible_message_count: usize,
}
pub struct Agent {
provider: Arc<dyn Provider>,
registry: Registry,
skills: Arc<SkillRegistry>,
session: Session,
active_skill: Option<String>,
allowed_tools: Option<HashSet<String>>,
disabled_tools: HashSet<String>,
/// Provider-specific session ID for conversation resume (e.g., Claude Code CLI session)
provider_session_id: Option<String>,
/// Last upstream provider (OpenRouter) observed for this session
last_upstream_provider: Option<String>,
/// Last observed transport/connection type for this session
last_connection_type: Option<String>,
/// Last provider-supplied human-readable transport detail for this session
last_status_detail: Option<String>,
/// Pending swarm alerts to inject into the next turn
pending_alerts: Vec<String>,
/// Transient reminder injected into provider requests for the current turn only.
/// Not persisted to session history.
current_turn_system_reminder: Option<String>,
/// Tool call ids observed in the current session transcript.
tool_call_ids: HashSet<String>,
/// Tool result ids observed in the current session transcript.
tool_result_ids: HashSet<String>,
/// Number of stored session messages already indexed for missing tool-output repair.
tool_output_scan_index: usize,
/// Soft interrupt queue: messages to inject at next safe point without cancelling
/// Uses std::sync::Mutex so it can be accessed without async, even while agent is processing
soft_interrupt_queue: SoftInterruptQueue,
/// Signal from client to move the currently executing tool to background
background_tool_signal: InterruptSignal,
/// Signal to gracefully stop generation (checkpoint partial response and exit)
graceful_shutdown: InterruptSignal,
/// Client-side cache tracking for detecting append-only violations
cache_tracker: CacheTracker,
/// Last token usage from API request (for debug socket queries)
last_usage: TokenUsage,
/// Locked tool list: once the first API request is sent, freeze the tool list
/// to avoid cache invalidation when MCP tools arrive asynchronously.
/// Cleared on compaction/reset.
locked_tools: Option<Vec<ToolDefinition>>,
/// One-shot guard for the async MCP-registration race (#206).
///
/// MCP servers connect on a background task and register `mcp__*` tools
/// seconds after the session starts (we deliberately do NOT block the first
/// turn on MCP connection, so the user can talk to the agent immediately).
/// The first turn therefore locks a snapshot without MCP tools. We allow
/// exactly one rebuild to pick them up — an intentional, one-time provider
/// prompt-cache miss. Once that rebuild happens (or we confirm there are no
/// MCP tools to wait for), this is set so the per-turn registry scan stops.
/// Reset whenever the tool list is intentionally unlocked.
mcp_late_register_resolved: bool,
/// Override system prompt (used by ambient mode to inject a custom prompt)
system_prompt_override: Option<String>,
/// Whether memory features are enabled for this session
memory_enabled: bool,
/// One-step undo snapshot captured before the most recent rewind.
rewind_undo_snapshot: Option<RewindUndoSnapshot>,
/// Channel for tools to request stdin input from the user
stdin_request_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::tool::StdinInputRequest>>,
/// Canonical reducer-backed view of runtime provider/model selection.
provider_runtime_state: ProviderRuntimeState,
/// When true, this session is an inline swarm worker: stream a throttled
/// output tail to the global bus so the coordinator's inline gallery can
/// render a live viewport. Off for normal sessions to avoid bus traffic.
inline_output_tap: bool,
/// Rolling activity tail (text + tool markers) for the inline output tap.
/// Persists across turns so the coordinator's viewport never blanks at
/// turn boundaries or freezes during long tool calls.
inline_tail: inline_tail::InlineTailBuffer,
}
impl Agent {
fn should_track_client_cache(&self) -> bool {
match std::env::var("JCODE_TRACK_CLIENT_CACHE") {
Ok(value) => {
let value = value.trim();
!value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
}
Err(_) => false,
}
}
fn build_base(
provider: Arc<dyn Provider>,
registry: Registry,
session: Session,
allowed_tools: Option<HashSet<String>>,
disabled_tools: HashSet<String>,
) -> Self {
let skills = SkillRegistry::shared_snapshot();
let initial_provider_model = provider.model();
let agent = Self {
provider,
registry,
skills,
session,
active_skill: None,
allowed_tools,
disabled_tools,
provider_session_id: None,
last_upstream_provider: None,
last_connection_type: None,
last_status_detail: None,
pending_alerts: Vec::new(),
current_turn_system_reminder: None,
tool_call_ids: HashSet::new(),
tool_result_ids: HashSet::new(),
tool_output_scan_index: 0,
soft_interrupt_queue: Arc::new(std::sync::Mutex::new(Vec::new())),
background_tool_signal: InterruptSignal::new(),
graceful_shutdown: InterruptSignal::new(),
cache_tracker: CacheTracker::new(),
last_usage: TokenUsage::default(),
locked_tools: None,
mcp_late_register_resolved: false,
system_prompt_override: None,
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
}
fn current_skills_snapshot(&self) -> Arc<SkillRegistry> {
// Global skills come from the process-wide shared registry; the
// project-local overlay is composed fresh from this session's
// workspace root so per-repo skills are session-scoped, immediately
// visible, and never leak across sessions (issue #457).
let global = self
.registry
.skills()
.try_read()
.map(|skills| Arc::new(skills.clone()))
.unwrap_or_else(|_| self.skills.clone());
let working_dir = self
.session
.working_dir
.as_deref()
.map(std::path::Path::new);
Arc::new(SkillRegistry::effective_for_working_dir(
&global,
working_dir,
))
}
pub fn available_skill_names(&self) -> Vec<String> {
self.current_skills_snapshot()
.list()
.iter()
.map(|skill| skill.name.clone())
.collect()
}
pub fn new(provider: Arc<dyn Provider>, registry: Registry) -> Self {
Self::new_with_initial_working_dir(provider, registry, None)
}
pub(crate) fn new_with_initial_working_dir(
provider: Arc<dyn Provider>,
registry: Registry,
working_dir: Option<&str>,
) -> Self {
let tool_selection = crate::config::config().tools.selection();
let mut session = Session::create(None, None);
if let Some(working_dir) = working_dir {
session.working_dir = Some(working_dir.to_string());
}
let mut agent = Self::build_base(
provider,
registry,
session,
tool_selection.allowed_tools,
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.ensure_initial_session_context_message();
agent.seed_compaction_from_session();
agent.log_env_snapshot("create");
agent.fire_session_lifecycle_hook("session_start", "create");
crate::telemetry::begin_session_with_parent(
agent.provider.name(),
&agent.provider.model(),
agent.session.parent_id.clone(),
false,
);
agent
}
pub fn new_with_session(
provider: Arc<dyn Provider>,
registry: Registry,
session: Session,
allowed_tools: Option<HashSet<String>>,
) -> Self {
let tool_selection = if let Some(allowed_tools) = allowed_tools {
crate::config::ToolSelection {
allowed_tools: Some(allowed_tools),
disabled_tools: HashSet::new(),
}
} else {
crate::config::config().tools.selection()
};
let mut agent = Self::build_base(
provider,
registry,
session,
tool_selection.allowed_tools,
tool_selection.disabled_tools,
);
agent.session.mark_active();
if agent.session.provider_key.is_none() {
agent.session.provider_key =
crate::session::derive_session_provider_key(agent.provider.name());
}
if let Some(model) = agent.session.model.clone() {
let model_request =
crate::provider::MultiProvider::model_switch_request_for_session_route(
&model,
agent.session.provider_key.as_deref(),
agent.session.route_api_method.as_deref(),
);
if let Err(e) = crate::provider::set_model_with_auth_refresh(
agent.provider.as_ref(),
&model_request,
) {
logging::error(&format!(
"Failed to restore session model '{}' via '{}': {}",
model, model_request, e
));
}
} else {
agent.session.model = Some(agent.provider.model());
}
agent.restore_reasoning_effort_from_session();
agent.session.ensure_initial_session_context_message();
agent.sync_memory_dedup_state_from_session();
agent.seed_compaction_from_session();
agent.log_env_snapshot("attach");
agent.fire_session_lifecycle_hook("session_start", "attach");
crate::telemetry::begin_session_with_parent(
agent.provider.name(),
&agent.provider.model(),
agent.session.parent_id.clone(),
false,
);
agent
}
fn seed_compaction_from_session(&mut self) {
logging::info(&format!(
"seed_compaction_from_session: session has {} messages",
self.session.messages.len()
));
let compaction = self.registry.compaction();
let mut manager = match compaction.try_write() {
Ok(manager) => manager,
Err(_) => {
logging::warn(
"seed_compaction_from_session: compaction lock unavailable, skipping restore",
);
return;
}
};
manager.reset();
let budget = self.provider.context_window();
manager.set_budget(budget);
if let Some(state) = self.session.compaction.as_ref() {
manager.restore_persisted_stored_state_with(state, &self.session.messages);
} else {
manager.seed_restored_stored_messages_with(&self.session.messages);
}
let sanitized_state = if manager.discard_oversized_openai_native_compaction() {
Some(manager.persisted_state())
} else {
None
};
logging::info(&format!(
"seed_compaction_from_session: seeded compaction with {} messages",
self.session.messages.len()
));
drop(manager);
if let Some(state) = sanitized_state {
self.session.compaction = state;
self.persist_session_best_effort("sanitized oversized OpenAI native compaction");
}
}
fn sync_memory_dedup_state_from_session(&self) {
crate::memory::sync_injected_memories(
&self.session.id,
&self.session.injected_memory_ids(),
);
}
fn record_memory_injection_in_session(&mut self, memory: &crate::memory::PendingMemory) {
let count = memory.count.max(1);
let age_ms = memory.computed_at.elapsed().as_millis() as u64;
let summary = if count == 1 {
"🧠 auto-recalled 1 memory".to_string()
} else {
format!("🧠 auto-recalled {} memories", count)
};
let display_prompt = memory.display_prompt.clone().unwrap_or_else(|| {
if memory.prompt.trim().is_empty() {
"# Memory\n\n## Notes\n1. (empty injection payload)".to_string()
} else {
memory.prompt.clone()
}
});
self.session.record_memory_injection(
summary,
display_prompt,
count as u32,
age_ms,
memory.memory_ids.clone(),
);
if let Err(err) = self.session.save() {
logging::warn(&format!(
"Failed to persist memory injection for session {}: {}",
self.session.id, err
));
}
}
fn memory_injection_message(memory: &crate::memory::PendingMemory) -> Message {
Message::user(&format!(
"<system-reminder>\n{}\n</system-reminder>",
memory.prompt
))
}
pub(super) fn prepare_memory_injection_message(
&mut self,
memory: &crate::memory::PendingMemory,
) -> (Message, bool) {
let message = Self::memory_injection_message(memory);
let persist = crate::config::config().features.persist_memory_injections;
if persist {
self.add_message_with_display_role(
Role::User,
message.content.clone(),
Some(StoredDisplayRole::System),
);
self.persist_session_best_effort("persisted memory injection message");
}
(message, persist)
}
fn persist_session_best_effort(&mut self, context: &str) {
if let Err(err) = self.session.save() {
logging::warn(&format!(
"Failed to persist {} for session {}: {}",
context, self.session.id, err
));
}
}
fn reset_runtime_state_for_session_change(&mut self) {
self.active_skill = None;
self.last_upstream_provider = None;
self.last_connection_type = None;
self.last_status_detail = None;
self.pending_alerts.clear();
self.current_turn_system_reminder = None;
self.reset_tool_output_tracking();
if let Ok(mut queue) = self.soft_interrupt_queue.lock() {
queue.clear();
}
self.background_tool_signal.reset();
self.graceful_shutdown.reset();
self.cache_tracker.reset();
self.last_usage = TokenUsage::default();
self.locked_tools = None;
self.mcp_late_register_resolved = false;
self.rewind_undo_snapshot = None;
}
fn sync_session_compaction_state_from_manager(
&mut self,
manager: &crate::compaction::CompactionManager,
) {
let new_state = manager.persisted_state();
if self.session.compaction != new_state {
self.session.compaction = new_state;
if let Err(err) = self.session.save() {
logging::error(&format!(
"Failed to persist compaction state for session {}: {}",
self.session.id, err
));
}
}
}
fn apply_openai_native_compaction(
&mut self,
encrypted_content: String,
compacted_count: usize,
) -> Result<()> {
let encrypted_content_len = encrypted_content.len();
let (summary_text, openai_encrypted_content) =
if crate::provider::openai_request::openai_encrypted_content_is_sendable(
&encrypted_content,
) {
(String::new(), Some(encrypted_content))
} else {
logging::warn(&format!(
"Discarding oversized OpenAI native compaction payload before persist ({} chars)",
encrypted_content_len,
));
(
crate::provider::openai_request::openai_encrypted_content_fallback_summary(
encrypted_content_len,
),
None,
)
};
let state = crate::session::StoredCompactionState {
summary_text,
openai_encrypted_content,
covers_up_to_turn: compacted_count,
original_turn_count: compacted_count,
compacted_count,
};
self.session.compaction = Some(state.clone());
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
manager.set_budget(self.provider.context_window());
manager.restore_persisted_stored_state_with(&state, &self.session.messages);
}
self.cache_tracker.reset();
self.locked_tools = None;
self.mcp_late_register_resolved = false;
self.provider_session_id = None;
self.session.provider_session_id = None;
self.session.save()?;
crate::runtime_memory_log::emit_event(
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"native_compaction_applied",
"provider_native_compaction_persisted",
)
.with_session_id(self.session.id.clone())
.force_attribution(),
);
Ok(())
}
fn messages_for_provider(&mut self) -> (Vec<Message>, Option<CompactionEvent>) {
if self.provider.supports_compaction() || self.session.compaction.is_some() {
let compaction = self.registry.compaction();
match compaction.try_write() {
Ok(mut manager) => {
let discarded_oversized_native =
manager.discard_oversized_openai_native_compaction();
let messages = {
let all_messages = self.session.provider_messages();
if self.provider.uses_jcode_compaction() {
let action =
manager.ensure_context_fits(all_messages, self.provider.clone());
match action {
crate::compaction::CompactionAction::BackgroundStarted {
trigger,
} => {
logging::info(&format!(
"Background compaction started ({})",
trigger
));
}
crate::compaction::CompactionAction::HardCompacted(dropped) => {
logging::warn(&format!(
"Emergency hard compact: dropped {} messages (context was critical)",
dropped
));
}
crate::compaction::CompactionAction::None => {}
}
}
manager.messages_for_api_with(all_messages)
};
let event = manager.take_compaction_event();
if event.is_some() || discarded_oversized_native {
self.sync_session_compaction_state_from_manager(&manager);
}
if event.is_some() {
self.note_compaction_applied();
self.persist_session_best_effort("compaction completion");
}
let user_count = messages
.iter()
.filter(|message| matches!(message.role, Role::User))
.count();
let assistant_count = messages.len().saturating_sub(user_count);
logging::info(&format!(
"messages_for_provider (compaction): returning {} messages (user={}, assistant={})",
messages.len(),
user_count,
assistant_count,
));
return (messages, event);
}
Err(_) => {
logging::info("messages_for_provider: compaction lock failed, using session");
}
};
}
let all_messages = self.session.provider_messages();
let messages = all_messages.to_vec();
let user_count = messages
.iter()
.filter(|message| matches!(message.role, Role::User))
.count();
let assistant_count = messages.len().saturating_sub(user_count);
logging::info(&format!(
"messages_for_provider (session): returning {} messages (user={}, assistant={})",
messages.len(),
user_count,
assistant_count,
));
(messages, None)
}
fn record_client_cache_request(&mut self, messages: &[Message]) {
if !self.should_track_client_cache() {
return;
}
let fast_snapshot =
if !self.provider.uses_jcode_compaction() && self.session.compaction.is_none() {
let previous_count = self.cache_tracker.previous_message_count();
let prefix_hashes = self.session.provider_message_prefix_hashes();
let current_count = prefix_hashes.len();
let current_full_hash = prefix_hashes.last().copied();
let prefix_hash_at_previous_count =
if previous_count == 0 || previous_count > current_count {
None
} else {
Some(prefix_hashes[previous_count - 1])
};
Some((
current_count,
prefix_hash_at_previous_count,
current_full_hash,
))
} else {
None
};
let violation =
if let Some((current_count, prefix_hash_at_previous_count, current_full_hash)) =
fast_snapshot
{
self.cache_tracker.record_prefix_hash_snapshot(
current_count,
prefix_hash_at_previous_count,
current_full_hash,
)
} else {
self.cache_tracker.record_request(messages)
};
if let Some(violation) = violation {
logging::warn(&format!(
"CLIENT_CACHE_VIOLATION: {} | turn={} messages={}",
violation.reason, violation.turn, violation.message_count
));
}
}
fn repair_missing_tool_outputs(&mut self) -> usize {
if self.tool_output_scan_index > self.session.messages.len() {
self.reset_tool_output_tracking();
}
let scan_start = self.tool_output_scan_index;
let mut new_result_ids = Vec::new();
let mut assistant_tool_uses: Vec<(usize, Vec<String>)> = Vec::new();
for (index, msg) in self.session.messages.iter().enumerate().skip(scan_start) {
match msg.role {
Role::User => {
for block in &msg.content {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
new_result_ids.push(tool_use_id.clone());
}
}
}
Role::Assistant => {
let tool_uses = msg
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.clone()),
_ => None,
})
.collect::<Vec<_>>();
if !tool_uses.is_empty() {
assistant_tool_uses.push((index, tool_uses));
}
}
}
}
self.tool_result_ids.extend(new_result_ids);
let mut missing_repairs: Vec<(usize, Vec<String>)> = Vec::new();
for (index, tool_uses) in assistant_tool_uses {
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 !missing_for_message.is_empty() {
missing_repairs.push((index, missing_for_message));
}
}
self.tool_output_scan_index = self.session.messages.len();
let mut repaired = 0usize;
let mut inserted = 0usize;
for (index, missing_for_message) in missing_repairs {
for (offset, id) in missing_for_message.iter().enumerate() {
let tool_block = ContentBlock::ToolResult {
tool_use_id: id.clone(),
content: TOOL_OUTPUT_MISSING_TEXT.to_string(),
is_error: Some(true),
};
let stored_message = StoredMessage {
id: id::new_id("message"),
role: Role::User,
content: vec![tool_block],
display_role: None,
timestamp: Some(chrono::Utc::now()),
tool_duration_ms: None,
token_usage: None,
};
self.session
.insert_message(index + 1 + inserted + offset, stored_message);
self.tool_result_ids.insert(id.clone());
repaired += 1;
}
inserted += missing_for_message.len();
}
self.tool_output_scan_index = self.session.messages.len();
if repaired > 0 {
self.persist_session_best_effort("missing tool-output repair");
self.cache_tracker.reset();
self.locked_tools = None;
self.mcp_late_register_resolved = false;
}
repaired
}
fn reset_tool_output_tracking(&mut self) {
self.tool_call_ids.clear();
self.tool_result_ids.clear();
self.tool_output_scan_index = 0;
}
pub fn session_id(&self) -> &str {
&self.session.id
}
pub(crate) fn set_working_dir_for_pending_context(&mut self, working_dir: Option<String>) {
if working_dir.is_some() {
self.session.working_dir = working_dir;
self.session.refresh_initial_session_context_message();
}
}
/// 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.persist_soft_interrupt_snapshot();
self.session.mark_closed();
if !self.session.messages.is_empty() {
self.persist_session_best_effort("session close state");
}
self.fire_session_lifecycle_hook("session_end", "close");
}
/// Fire a session lifecycle observer hook (`session_start`/`session_end`).
/// No-op when the hook is not configured.
pub(crate) fn fire_session_lifecycle_hook(&self, event_name: &'static str, source: &str) {
if !crate::hooks::hook_configured(event_name) {
return;
}
let mut event = crate::hooks::HookEvent::new(event_name)
.session_id(self.session.id.clone())
.field("SOURCE", source)
.field("MODEL", self.provider_model());
if let Some(cwd) = self.working_dir() {
event = event.cwd(cwd);
}
crate::hooks::dispatch_observer(event);
}
pub fn mark_crashed(&mut self, message: Option<String>) {
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");
}
}
/// Get the last token usage from the most recent API request
pub fn last_usage(&self) -> &TokenUsage {
&self.last_usage
}
pub fn token_usage_totals(&self) -> crate::protocol::TokenUsageTotals {
self.session.token_usage_totals()
}
/// Export the full conversation as a markdown transcript.
pub fn export_conversation_markdown(&self) -> String {
let mut md = String::new();
for msg in &self.session.messages {
let role_label = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
md.push_str(&format!("### {}\n\n", role_label));
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
md.push_str(text);
md.push_str("\n\n");
}
ContentBlock::Reasoning { text } => {
md.push_str(&format!("*Thinking:* {}\n\n", text));
}
ContentBlock::ReasoningTrace { text } => {
md.push_str(&format!("*Thinking:* {}\n\n", text));
}
ContentBlock::AnthropicThinking { thinking, .. } => {
md.push_str(&format!("*Thinking:* {}\n\n", thinking));
}
ContentBlock::OpenAIReasoning { summary, .. } => {
if !summary.is_empty() {
md.push_str(&format!("*Thinking:* {}\n\n", summary.join("\n")));
}
}
ContentBlock::ToolUse { name, input, .. } => {
let input_str = serde_json::to_string_pretty(input)
.unwrap_or_else(|_| input.to_string());
md.push_str(&format!(
"**Tool: `{}`**\n```json\n{}\n```\n\n",
name, input_str
));
}
ContentBlock::ToolResult {
content, is_error, ..
} => {
let label = if is_error == &Some(true) {
"Error"
} else {
"Result"
};
// Truncate very long results
let display = if content.len() > 2000 {
format!(
"{}... (truncated, {} chars total)",
crate::util::truncate_str(content, 2000),
content.len()
)
} else {
content.clone()
};
md.push_str(&format!("**{}:**\n```\n{}\n```\n\n", label, display));
}
ContentBlock::Image { .. } => {
md.push_str("[Image]\n\n");
}
ContentBlock::OpenAICompaction { .. } => {
md.push_str("[OpenAI native compaction]\n\n");
}
}
}
}
md
}
}
#[cfg(test)]
#[path = "agent_tests.rs"]
mod tests;
@@ -0,0 +1,344 @@
use super::*;
impl Agent {
pub(super) fn note_compaction_applied(&mut self) {
self.cache_tracker.reset();
self.locked_tools = None;
self.provider_session_id = None;
self.session.provider_session_id = None;
}
pub fn poll_compaction_completion_event(&mut self) -> Option<CompactionEvent> {
let provider_messages = self.session.messages_for_provider();
let compaction = self.registry.compaction();
let event = match compaction.try_write() {
Ok(mut manager) => {
let event = manager.poll_compaction_event_with(&provider_messages);
if event.is_some() {
self.sync_session_compaction_state_from_manager(&manager);
}
event
}
Err(_) => return None,
};
if event.is_some() {
self.note_compaction_applied();
self.persist_session_best_effort("compaction completion");
}
event
}
pub fn request_manual_compaction(&mut self) -> (String, bool) {
if !self.provider.supports_compaction() {
return (
"Manual compaction is not available for this provider.".to_string(),
false,
);
}
let provider = self.provider.fork();
let messages = self.session.messages_for_provider();
let compaction = self.registry.compaction();
match compaction.try_write() {
Ok(mut manager) => {
let stats = manager.stats_with(&messages);
let status_msg = format!(
"**Context Status:**\n\
• Messages: {} (active), {} (total history)\n\
• Token usage: ~{}k (estimate ~{}k) / {}k ({:.1}%)\n\
• Has summary: {}\n\
• Compacting: {}",
stats.active_messages,
stats.total_turns,
stats.effective_tokens / 1000,
stats.token_estimate / 1000,
manager.token_budget() / 1000,
stats.context_usage * 100.0,
if stats.has_summary { "yes" } else { "no" },
if stats.is_compacting {
"in progress..."
} else {
"no"
}
);
match manager.force_compact_with(&messages, provider) {
Ok(()) => (
format!(
"{}\n\n📦 **Compacting context** (manual) — summarizing older messages in the background to stay within the context window.\n\
The summary will be applied automatically when ready.",
status_msg
),
true,
),
Err(reason) => (
format!("{status_msg}\n\n⚠ **Cannot compact:** {reason}"),
false,
),
}
}
Err(_) => (
"⚠ Cannot access compaction manager (lock held)".to_string(),
false,
),
}
}
fn is_context_limit_error(error: &str) -> bool {
let lower = error.to_lowercase();
lower.contains("context length")
|| lower.contains("context window")
|| lower.contains("maximum context")
|| lower.contains("max context")
|| lower.contains("token limit")
|| lower.contains("too many tokens")
|| lower.contains("prompt is too long")
|| lower.contains("input is too long")
|| lower.contains("request too large")
|| lower.contains("length limit")
|| lower.contains("maximum tokens")
|| (lower.contains("exceeded") && lower.contains("tokens"))
}
/// Best-effort emergency recovery after a context-limit error.
///
/// Performs a synchronous hard compaction and resets provider session state,
/// allowing the caller to retry the same turn immediately.
pub(super) fn try_auto_compact_after_context_limit(&mut self, error: &str) -> bool {
if crate::provider::openai_request::is_openai_encrypted_content_too_large_error(error)
&& self.try_recover_oversized_openai_native_compaction()
{
return true;
}
// A provider HTTP 413 ("request too large") is a *byte-size* failure
// driven by inline base64 images, not a token-context overflow. Token
// accounting deliberately undercounts images, so ordinary compaction
// would not shrink the payload and the retry would 413 again. Strip
// oversized images first.
if self.try_recover_after_payload_too_large(error) {
return true;
}
if !Self::is_context_limit_error(error) {
return false;
}
if !self.provider.supports_compaction() {
return false;
}
let context_limit = self.provider.context_window() as u64;
let compaction = self.registry.compaction();
let (dropped, usage_pct) = match compaction.try_write() {
Ok(mut manager) => {
let (dropped, usage_pct) = {
let all_messages = self.session.provider_messages();
manager.update_observed_input_tokens(context_limit);
let usage_pct = manager.context_usage_with(all_messages) * 100.0;
let dropped = match manager.hard_compact_with(all_messages) {
Ok(dropped) => dropped,
Err(reason) => {
logging::warn(&format!(
"Context-limit auto-recovery failed: hard compact failed ({})",
reason
));
return false;
}
};
(dropped, usage_pct)
};
self.sync_session_compaction_state_from_manager(&manager);
(dropped, usage_pct)
}
Err(_) => {
logging::warn("Context-limit auto-recovery skipped: compaction manager lock busy");
return false;
}
};
self.cache_tracker.reset();
self.locked_tools = None;
self.provider_session_id = None;
self.session.provider_session_id = None;
logging::warn(&format!(
"Context limit exceeded; auto-compacted and retrying (dropped {} messages, usage was {:.1}%)",
dropped, usage_pct
));
crate::runtime_memory_log::emit_event(
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"auto_compaction_applied",
"context_limit_auto_compaction",
)
.with_session_id(self.session.id.clone())
.with_detail(format!(
"dropped_messages={dropped},usage_pct={usage_pct:.1}"
))
.force_attribution(),
);
true
}
/// Best-effort recovery after a provider HTTP 413 "request too large" error.
///
/// This failure is caused by the serialized request body (dominated by inline
/// base64 images) exceeding the provider's size cap, which is independent of
/// the token context window. We strip oversized images from the persisted
/// transcript, oldest-first, down to a conservative byte budget and reset the
/// provider session/cache so the caller can retry the same turn immediately.
fn try_recover_after_payload_too_large(&mut self, error: &str) -> bool {
if !crate::compaction::is_request_payload_too_large_error(error) {
return false;
}
let stripped = self
.session
.strip_oversized_images(crate::compaction::PAYLOAD_IMAGE_CHAR_BUDGET);
if stripped == 0 {
logging::warn(
"Request-too-large recovery skipped: no oversized inline images to strip",
);
return false;
}
// The transcript changed; reseed compaction bookkeeping and reset
// provider session/cache state so the retry sends the reduced payload.
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
let provider_messages = self.session.messages_for_provider();
manager.reset();
manager.set_budget(self.provider.context_window());
if let Some(state) = self.session.compaction.as_ref() {
manager.restore_persisted_state_with(state, &provider_messages);
} else {
manager.seed_restored_messages_with(&provider_messages);
}
self.sync_session_compaction_state_from_manager(&manager);
}
self.cache_tracker.reset();
self.locked_tools = None;
self.provider_session_id = None;
self.session.provider_session_id = None;
logging::warn(&format!(
"Request body exceeded provider size limit; stripped {} oversized inline image(s) and retrying",
stripped
));
crate::runtime_memory_log::emit_event(
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"payload_too_large_recovered",
"request_payload_too_large",
)
.with_session_id(self.session.id.clone())
.with_detail(format!("images_stripped={stripped}"))
.force_attribution(),
);
true
}
fn try_recover_oversized_openai_native_compaction(&mut self) -> bool {
let compaction = self.registry.compaction();
let recovered = match compaction.try_write() {
Ok(mut manager) => {
if !manager.discard_oversized_openai_native_compaction() {
return false;
}
self.sync_session_compaction_state_from_manager(&manager);
true
}
Err(_) => {
logging::warn(
"OpenAI native compaction recovery skipped: compaction manager lock busy",
);
false
}
};
if !recovered {
return false;
}
self.cache_tracker.reset();
self.locked_tools = None;
self.provider_session_id = None;
self.session.provider_session_id = None;
logging::warn(
"OpenAI native compaction payload exceeded provider size limit; discarded native state and retrying with text fallback",
);
crate::runtime_memory_log::emit_event(
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"native_compaction_payload_recovered",
"openai_encrypted_content_too_large",
)
.with_session_id(self.session.id.clone())
.force_attribution(),
);
true
}
fn effective_context_tokens_from_usage(
&self,
input_tokens: u64,
cache_read_input_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
) -> u64 {
// Shared heuristic (jcode-compaction-core): keeps the compaction
// manager's observed-token feed consistent with the client-side
// context display.
crate::compaction::effective_context_tokens_from_usage(
self.provider.name(),
input_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
)
}
pub(super) fn update_compaction_usage_from_stream(
&mut self,
input_tokens: u64,
cache_read_input_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
) {
if !self.provider.uses_jcode_compaction() || input_tokens == 0 {
return;
}
let observed = self.effective_context_tokens_from_usage(
input_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
manager.update_observed_input_tokens(observed);
manager.push_token_snapshot(observed);
};
}
/// Push an embedding snapshot for the semantic compaction mode.
/// Called after each assistant turn with a short text snippet.
/// No-op if the embedding model is unavailable or mode is not semantic.
pub(super) fn push_embedding_snapshot_if_semantic(&mut self, text: &str) {
use crate::config::CompactionMode;
let is_semantic = {
let compaction = self.registry.compaction();
compaction
.try_read()
.map(|m| m.mode() == CompactionMode::Semantic)
.unwrap_or(false)
};
if !is_semantic {
return;
}
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
manager.push_embedding_snapshot(text);
};
}
}
@@ -0,0 +1,98 @@
use super::{Agent, JCODE_REPO_SOURCE_STATE, WORKING_GIT_STATE_CACHE};
use crate::logging;
use crate::session::{EnvSnapshot, GitState};
use chrono::Utc;
use std::path::Path;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum EnvSnapshotDetail {
Minimal,
Full,
}
pub(super) fn cached_git_state_for_dir(
dir: &Path,
git_state_for_dir: impl Fn(&Path) -> Option<GitState>,
) -> Option<GitState> {
let cache_key = dir.to_path_buf();
if let Ok(cache) = WORKING_GIT_STATE_CACHE.lock()
&& let Some(state) = cache.get(&cache_key)
{
return state.clone();
}
let state = git_state_for_dir(dir);
if let Ok(mut cache) = WORKING_GIT_STATE_CACHE.lock() {
cache.insert(cache_key, state.clone());
}
state
}
impl Agent {
/// Set logging context for this agent's session/provider
pub(super) fn set_log_context(&self) {
logging::set_session(&self.session.id);
logging::set_provider_info(self.provider.name(), &self.provider.model());
}
/// Record a lightweight environment snapshot for post-mortem debugging
pub(super) fn log_env_snapshot(&mut self, reason: &str) {
let snapshot = self.build_env_snapshot(reason, self.env_snapshot_detail());
self.session.record_env_snapshot(snapshot.clone());
if !self.session.messages.is_empty() {
self.persist_session_best_effort("environment snapshot");
}
if let Ok(json) = serde_json::to_string(&snapshot) {
logging::info(&format!("ENV_SNAPSHOT {}", json));
} else {
logging::info("ENV_SNAPSHOT {}");
}
}
pub(super) fn env_snapshot_detail(&self) -> EnvSnapshotDetail {
if self.session.visible_conversation_message_count() == 0 {
EnvSnapshotDetail::Minimal
} else {
EnvSnapshotDetail::Full
}
}
pub(super) fn build_env_snapshot(
&self,
reason: &str,
detail: EnvSnapshotDetail,
) -> EnvSnapshot {
let (jcode_git_hash, jcode_git_dirty) = match detail {
EnvSnapshotDetail::Full => JCODE_REPO_SOURCE_STATE.clone(),
EnvSnapshotDetail::Minimal => (None, None),
};
let working_dir = self.session.working_dir.clone();
let working_git = match detail {
EnvSnapshotDetail::Full => working_dir.as_deref().and_then(|dir| {
cached_git_state_for_dir(Path::new(dir), super::utils::git_state_for_dir)
}),
EnvSnapshotDetail::Minimal => None,
};
EnvSnapshot {
captured_at: Utc::now(),
reason: reason.to_string(),
session_id: self.session.id.clone(),
working_dir,
provider: self.provider.name().to_string(),
model: self.provider.model().to_string(),
jcode_version: jcode_build_meta::VERSION.to_string(),
jcode_git_hash,
jcode_git_dirty,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
pid: std::process::id(),
is_selfdev: self.session.is_self_dev(),
is_debug: self.session.is_debug,
is_canary: self.session.is_canary,
testing_build: self.session.testing_build.clone(),
working_git,
}
}
}
@@ -0,0 +1,285 @@
//! Rolling live-output tail for inline swarm workers.
//!
//! The coordinator's inline gallery/dock renders a small viewport of each
//! worker's recent activity. Streaming only the in-progress assistant text
//! had two failure modes:
//!
//! 1. Workers spend most wall-clock time inside tool calls, during which no
//! text streams, so the viewport froze on stale prose for the duration.
//! 2. `text_content` resets on every API call, so the viewport blanked at
//! each turn/continuation boundary.
//!
//! [`InlineTailBuffer`] fixes both: it keeps a rolling, capped buffer of
//! *committed* activity lines (finished text segments and tool markers) that
//! survives across turns, plus the current *live* streaming text segment.
//! Tool executions are interleaved as `⚙ name · summary` markers, updated in
//! place with a duration or error state on completion.
use std::collections::VecDeque;
/// Max committed lines retained (matches the gallery viewport budget).
const MAX_LINES: usize = 14;
/// Max total characters in the rendered tail (bus payload cap).
const MAX_CHARS: usize = 1400;
/// Max characters of a tool marker's input summary.
const MAX_SUMMARY_CHARS: usize = 60;
/// Rolling tail of a worker's recent activity: committed lines (text +
/// tool markers) plus the live in-progress assistant text.
#[derive(Debug, Default)]
pub(crate) struct InlineTailBuffer {
/// Finished activity lines, oldest first, capped to [`MAX_LINES`].
committed: VecDeque<String>,
/// In-progress assistant text for the current stream (replaced wholesale
/// on every delta, committed at message end, discarded on rollback).
live: String,
/// Whether the last committed line is an in-flight tool marker that
/// [`Self::finish_tool`] should update in place.
pending_tool_marker: bool,
}
impl InlineTailBuffer {
/// Replace the live streaming text with the accumulated `text` so far.
pub(crate) fn set_live(&mut self, text: &str) {
self.live.clear();
self.live.push_str(text);
}
/// Commit the live text into the rolling buffer (end of a message) and
/// clear it. Empty/whitespace-only live text is discarded.
pub(crate) fn commit_live(&mut self) {
let live = std::mem::take(&mut self.live);
for line in live.lines().filter(|l| !l.trim().is_empty()) {
self.push_committed(line.to_string());
}
}
/// Discard the live text (mid-stream retry rollback replays from the top).
pub(crate) fn clear_live(&mut self) {
self.live.clear();
}
/// Record a tool execution starting. Any live text is committed first so
/// ordering in the tail matches what actually happened.
pub(crate) fn start_tool(&mut self, name: &str, input: &serde_json::Value) {
self.commit_live();
let summary = tool_marker_summary(name, input);
let marker = if summary.is_empty() {
format!("{name}")
} else {
format!("{name} · {summary}")
};
self.push_committed(marker);
self.pending_tool_marker = true;
}
/// Record the in-flight tool finishing, updating its marker in place with
/// a duration (and error flag). If the marker was already evicted by
/// buffer pressure, this is a no-op.
pub(crate) fn finish_tool(&mut self, elapsed_secs: f64, is_error: bool) {
if !self.pending_tool_marker {
return;
}
self.pending_tool_marker = false;
if let Some(last) = self.committed.back_mut() {
let status = if is_error { "" } else { "" };
last.push_str(&format!(" ({}){status}", humanize_secs(elapsed_secs)));
}
}
/// Render the tail for the bus: committed lines then live lines, bounded
/// to the last [`MAX_LINES`] lines / [`MAX_CHARS`] chars.
pub(crate) fn render(&self) -> String {
let live_lines = self
.live
.lines()
.filter(|l| !l.trim().is_empty())
.collect::<Vec<_>>();
let mut lines: Vec<&str> = self
.committed
.iter()
.map(String::as_str)
.chain(live_lines)
.collect();
if lines.len() > MAX_LINES {
lines.drain(..lines.len() - MAX_LINES);
}
let mut tail = lines.join("\n");
if tail.len() > MAX_CHARS {
let start = floor_char_boundary(&tail, tail.len() - MAX_CHARS);
tail = tail[start..].to_string();
}
tail
}
fn push_committed(&mut self, line: String) {
// A new committed line supersedes any pending in-place marker update
// ordering (finish_tool only touches the true last line).
self.pending_tool_marker = false;
self.committed.push_back(line);
while self.committed.len() > MAX_LINES {
self.committed.pop_front();
}
}
}
/// Compact one-line summary of a tool's input for the activity marker.
/// Prefers the model-provided `intent`, then well-known per-tool fields.
fn tool_marker_summary(name: &str, input: &serde_json::Value) -> String {
let raw = jcode_message_types::ToolCall::intent_from_input(input)
.or_else(|| {
let field = match name {
"bash" => "command",
"read" | "write" => "file_path",
"edit" | "multiedit" => "file_path",
"agentgrep" | "websearch" => "query",
"webfetch" => "url",
"task" | "subagent" => "description",
_ => return None,
};
input
.get(field)
.and_then(|v| v.as_str())
.map(str::to_string)
})
.unwrap_or_default();
let flat = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() > MAX_SUMMARY_CHARS {
let mut out: String = flat.chars().take(MAX_SUMMARY_CHARS - 1).collect();
out.push('…');
out
} else {
flat
}
}
/// "3s" / "2m10s" style duration for tool markers.
fn humanize_secs(secs: f64) -> String {
if secs < 10.0 {
format!("{secs:.1}s")
} else if secs < 60.0 {
format!("{}s", secs as u64)
} else {
let total = secs as u64;
format!("{}m{}s", total / 60, total % 60)
}
}
/// Largest byte index `<= index` that is a UTF-8 char boundary in `text`.
fn floor_char_boundary(text: &str, index: usize) -> usize {
if index >= text.len() {
return text.len();
}
let mut boundary = index;
while boundary > 0 && !text.is_char_boundary(boundary) {
boundary -= 1;
}
boundary
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_text_renders_and_survives_commit() {
let mut tail = InlineTailBuffer::default();
tail.set_live("thinking about the fix\nsecond line");
assert_eq!(tail.render(), "thinking about the fix\nsecond line");
tail.commit_live();
// Next stream starts blank but the previous output is retained.
tail.set_live("");
assert_eq!(tail.render(), "thinking about the fix\nsecond line");
}
#[test]
fn tool_markers_interleave_and_complete_in_place() {
let mut tail = InlineTailBuffer::default();
tail.set_live("Let me check the render pipeline.");
tail.start_tool(
"bash",
&serde_json::json!({"command": "cargo build --profile selfdev"}),
);
let mid = tail.render();
assert!(
mid.contains("Let me check the render pipeline."),
"live text must commit before the marker: {mid}"
);
assert!(
mid.contains("⚙ bash · cargo build --profile selfdev"),
"{mid}"
);
tail.finish_tool(47.2, false);
assert!(tail.render().contains("(47s)"), "{}", tail.render());
tail.start_tool("edit", &serde_json::json!({"file_path": "src/ui.rs"}));
tail.finish_tool(0.3, true);
let done = tail.render();
assert!(done.contains("⚙ edit · src/ui.rs (0.3s) ✗"), "{done}");
}
#[test]
fn marker_prefers_intent_over_raw_input() {
let mut tail = InlineTailBuffer::default();
tail.start_tool(
"bash",
&serde_json::json!({"command": "x", "intent": "run the ui tests"}),
);
assert!(tail.render().contains("⚙ bash · run the ui tests"));
}
#[test]
fn rollback_discards_live_but_keeps_committed() {
let mut tail = InlineTailBuffer::default();
tail.start_tool("read", &serde_json::json!({"file_path": "a.rs"}));
tail.finish_tool(0.1, false);
tail.set_live("partial output that gets replayed");
tail.clear_live();
let out = tail.render();
assert!(out.contains("⚙ read"), "{out}");
assert!(!out.contains("partial output"), "{out}");
}
#[test]
fn caps_lines_and_chars_and_summary_length() {
let mut tail = InlineTailBuffer::default();
for i in 0..40 {
tail.set_live(&format!("line number {i}"));
tail.commit_live();
}
let out = tail.render();
assert!(out.lines().count() <= MAX_LINES);
assert!(out.contains("line number 39"));
assert!(!out.contains("line number 0\n"));
let huge = "x".repeat(5000);
tail.set_live(&huge);
assert!(tail.render().len() <= MAX_CHARS);
let mut tail = InlineTailBuffer::default();
let long_cmd = "cargo test ".repeat(30);
tail.start_tool("bash", &serde_json::json!({ "command": long_cmd }));
let line = tail.render();
assert!(line.chars().count() < 80, "summary must truncate: {line}");
assert!(line.contains('…'), "{line}");
}
#[test]
fn finish_without_pending_marker_is_noop() {
let mut tail = InlineTailBuffer::default();
tail.set_live("hello");
tail.commit_live();
tail.finish_tool(1.0, false);
assert_eq!(tail.render(), "hello");
// Committing new lines invalidates a pending marker: finish after
// commit must not append a duration to unrelated text.
let mut tail = InlineTailBuffer::default();
tail.start_tool("bash", &serde_json::json!({"command": "ls"}));
tail.set_live("output line");
tail.commit_live();
tail.finish_tool(2.0, false);
let out = tail.render();
assert!(!out.contains("output line (2.0s)"), "{out}");
}
}
@@ -0,0 +1,458 @@
use super::Agent;
use crate::logging;
use crate::message::{ContentBlock, Role};
use crate::protocol::ServerEvent;
use crate::session::StoredDisplayRole;
use anyhow::Result;
use jcode_agent_runtime::{
InterruptSignal, SoftInterruptMessage, SoftInterruptQueue, SoftInterruptSource,
};
use std::sync::Arc;
fn soft_interrupt_session_display_role(source: SoftInterruptSource) -> Option<StoredDisplayRole> {
match source {
SoftInterruptSource::User => None,
SoftInterruptSource::System => Some(StoredDisplayRole::System),
SoftInterruptSource::BackgroundTask => Some(StoredDisplayRole::BackgroundTask),
}
}
fn soft_interrupt_protocol_display_role(source: SoftInterruptSource) -> Option<String> {
match source {
SoftInterruptSource::User => None,
SoftInterruptSource::System => Some("system".to_string()),
SoftInterruptSource::BackgroundTask => Some("background_task".to_string()),
}
}
#[derive(Debug, Clone)]
pub(super) struct InjectedSoftInterrupt {
pub(super) content: String,
pub(super) source: SoftInterruptSource,
}
pub(super) enum NoToolCallOutcome {
Break,
ContinueWithoutEvent,
ContinueWithSoftInterrupt {
injected: Vec<InjectedSoftInterrupt>,
point: &'static str,
},
}
pub(super) enum PostToolInterruptOutcome {
NoInterrupt,
SoftInterrupt {
injected: Vec<InjectedSoftInterrupt>,
point: &'static str,
},
}
impl Agent {
pub fn restore_persisted_soft_interrupts(&self) -> usize {
let restored = match crate::soft_interrupt_store::take(self.session_id()) {
Ok(items) => items,
Err(err) => {
logging::warn(&format!(
"Failed to restore persisted soft interrupts for {}: {}",
self.session_id(),
err
));
return 0;
}
};
if restored.is_empty() {
return 0;
}
let restored_count = restored.len();
if let Ok(mut queue) = self.soft_interrupt_queue.lock() {
queue.extend(restored);
} else {
logging::warn(&format!(
"Failed to restore persisted soft interrupts for {} because queue lock was poisoned",
self.session_id()
));
return 0;
}
logging::info(&format!(
"Restored {} persisted soft interrupt(s) for session {}",
restored_count,
self.session_id()
));
restored_count
}
pub fn persist_soft_interrupt_snapshot(&self) {
let pending = match self.soft_interrupt_queue.lock() {
Ok(queue) => queue.clone(),
Err(_) => {
logging::warn(&format!(
"Failed to snapshot soft interrupts for {} because queue lock was poisoned",
self.session_id()
));
return;
}
};
if let Err(err) = crate::soft_interrupt_store::overwrite(self.session_id(), &pending) {
logging::warn(&format!(
"Failed to persist {} soft interrupt(s) for {}: {}",
pending.len(),
self.session_id(),
err
));
}
}
/// Add a swarm alert to be injected into the next turn
pub fn push_alert(&mut self, alert: String) {
self.pending_alerts.push(alert);
}
/// Take all pending alerts (clears the queue)
pub fn take_alerts(&mut self) -> Vec<String> {
std::mem::take(&mut self.pending_alerts)
}
/// 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) {
let content_bytes = content.len();
let content_chars = content.chars().count();
if let Ok(mut queue) = self.soft_interrupt_queue.lock() {
let pending_before = queue.len();
queue.push(SoftInterruptMessage {
content,
urgent,
source,
});
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} pending_before={} pending_after={}",
self.session_id(),
source,
urgent,
content_bytes,
content_chars,
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",
self.session_id(),
source,
urgent,
content_bytes,
content_chars
));
}
}
/// Get a handle to the soft interrupt queue.
/// The server can use this to queue interrupts without holding the agent lock.
pub fn soft_interrupt_queue(&self) -> SoftInterruptQueue {
Arc::clone(&self.soft_interrupt_queue)
}
/// Get a handle to the background tool signal.
/// The server can use this to signal "move tool to background" without holding the agent lock.
pub fn background_tool_signal(&self) -> InterruptSignal {
self.background_tool_signal.clone()
}
pub fn graceful_shutdown_signal(&self) -> InterruptSignal {
self.graceful_shutdown.clone()
}
pub fn request_graceful_shutdown(&self) {
self.graceful_shutdown.fire();
}
pub(super) fn is_graceful_shutdown(&self) -> bool {
self.graceful_shutdown.is_set()
}
/// Check if there are pending soft interrupts
pub fn has_soft_interrupts(&self) -> bool {
self.soft_interrupt_queue
.lock()
.map(|q| !q.is_empty())
.unwrap_or(false)
}
/// Check if there's an urgent soft interrupt that should skip remaining tools
pub fn has_urgent_interrupt(&self) -> bool {
self.soft_interrupt_queue
.lock()
.map(|q| q.iter().any(|m| m.urgent))
.unwrap_or(false)
}
/// Get count of queued soft interrupts
pub fn soft_interrupt_count(&self) -> usize {
self.soft_interrupt_queue
.lock()
.map(|q| q.len())
.unwrap_or(0)
}
/// Get count of pending alerts
pub fn pending_alert_count(&self) -> usize {
self.pending_alerts.len()
}
/// Get pending alerts (for debug visibility)
pub fn pending_alerts_preview(&self) -> Vec<String> {
self.pending_alerts
.iter()
.take(10)
.map(|s| {
if s.len() > 100 {
format!("{}...", crate::util::truncate_str(s, 100))
} else {
s.clone()
}
})
.collect()
}
/// Get comprehensive debug info about agent internal state
pub fn debug_info(&self) -> serde_json::Value {
serde_json::json!({
"provider": self.provider.name(),
"model": self.provider.model(),
"provider_session_id": self.provider_session_id,
"last_upstream_provider": self.last_upstream_provider,
"last_connection_type": self.last_connection_type,
"active_skill": self.active_skill,
"allowed_tools": self.allowed_tools,
"disabled_tools": self.disabled_tools,
"session": {
"id": self.session.id,
"is_canary": self.session.is_canary,
"model": self.session.model,
"working_dir": self.session.working_dir,
"message_count": self.session.messages.len(),
},
"interrupts": {
"soft_interrupt_count": self.soft_interrupt_count(),
"has_urgent": self.has_urgent_interrupt(),
"pending_alert_count": self.pending_alert_count(),
"soft_interrupts": self.soft_interrupts_preview(),
"pending_alerts": self.pending_alerts_preview(),
},
"cache_tracker": {
"turn_count": self.cache_tracker.turn_count(),
"had_violation": self.cache_tracker.had_violation(),
},
"features": {
"memory_enabled": self.memory_enabled,
},
"token_usage": {
"input": self.last_usage.input_tokens,
"output": self.last_usage.output_tokens,
"cache_read": self.last_usage.cache_read_input_tokens,
"cache_write": self.last_usage.cache_creation_input_tokens,
},
})
}
pub fn debug_memory_profile(&self) -> serde_json::Value {
let process = crate::process_memory::snapshot_with_source("agent:memory");
let soft_interrupt_text_bytes: usize = self
.soft_interrupt_queue
.lock()
.map(|queue| queue.iter().map(|msg| msg.content.len()).sum())
.unwrap_or(0);
let pending_alert_text_bytes: usize =
self.pending_alerts.iter().map(|alert| alert.len()).sum();
serde_json::json!({
"process": process,
"session": self.session.debug_memory_profile(),
"interrupts": {
"soft_interrupt_count": self.soft_interrupt_count(),
"soft_interrupt_text_bytes": soft_interrupt_text_bytes,
"pending_alert_count": self.pending_alert_count(),
"pending_alert_text_bytes": pending_alert_text_bytes,
},
"agent": {
"memory_enabled": self.memory_enabled,
"allowed_tools_count": self.allowed_tools.as_ref().map(|tools| tools.len()),
"disabled_tools_count": self.disabled_tools.len(),
"tool_call_ids": self.tool_call_ids.len(),
"tool_result_ids": self.tool_result_ids.len(),
"last_usage": {
"input": self.last_usage.input_tokens,
"output": self.last_usage.output_tokens,
"cache_read": self.last_usage.cache_read_input_tokens,
"cache_write": self.last_usage.cache_creation_input_tokens,
},
}
})
}
/// Get soft interrupt previews (for debug visibility)
pub fn soft_interrupts_preview(&self) -> Vec<(String, bool)> {
self.soft_interrupt_queue
.lock()
.map(|q| {
q.iter()
.take(10)
.map(|m| {
let preview = if m.content.len() > 100 {
format!("{}...", crate::util::truncate_str(&m.content, 100))
} else {
m.content.clone()
};
(preview, m.urgent)
})
.collect()
})
.unwrap_or_default()
}
/// Inject all pending soft interrupt messages into the conversation.
/// Returns the combined message content and clears the queue.
pub(super) fn inject_soft_interrupts(&mut self) -> Vec<InjectedSoftInterrupt> {
let messages: Vec<SoftInterruptMessage> = {
let mut queue = match self.soft_interrupt_queue.lock() {
Ok(queue) => queue,
Err(_) => {
logging::warn(&format!(
"AGENT_SOFT_INTERRUPT_INJECT_LOCK_FAILED session={}",
self.session_id()
));
return Vec::new();
}
};
if queue.is_empty() {
return Vec::new();
}
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_INJECT_DRAIN session={} pending_count={} urgent_count={} total_content_bytes={}",
self.session_id(),
queue.len(),
queue.iter().filter(|message| message.urgent).count(),
queue
.iter()
.map(|message| message.content.len())
.sum::<usize>()
));
queue.drain(..).collect()
};
let mut injected = Vec::new();
let mut current_source: Option<SoftInterruptSource> = None;
let mut current_parts: Vec<String> = Vec::new();
let flush_group = |agent: &mut Self,
injected: &mut Vec<InjectedSoftInterrupt>,
source: SoftInterruptSource,
parts: &mut Vec<String>| {
if parts.is_empty() {
return;
}
let content = parts.join("\n\n");
parts.clear();
agent.add_message_with_display_role(
Role::User,
vec![ContentBlock::Text {
text: content.clone(),
cache_control: None,
}],
soft_interrupt_session_display_role(source),
);
injected.push(InjectedSoftInterrupt { content, source });
};
for message in messages {
match current_source {
Some(source) if source != message.source => {
flush_group(self, &mut injected, source, &mut current_parts);
current_source = Some(message.source);
}
None => current_source = Some(message.source),
_ => {}
}
current_parts.push(message.content);
}
if let Some(source) = current_source {
flush_group(self, &mut injected, source, &mut current_parts);
}
self.persist_session_best_effort("soft interrupt injection");
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_INJECT_COMMIT session={} groups={} total_content_bytes={}",
self.session_id(),
injected.len(),
injected
.iter()
.map(|interrupt| interrupt.content.len())
.sum::<usize>()
));
injected
}
pub(super) fn handle_streaming_no_tool_calls(
&mut self,
stop_reason: Option<&str>,
incomplete_continuations: &mut u32,
) -> Result<NoToolCallOutcome> {
if self.maybe_continue_incomplete_response(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() {
return Ok(NoToolCallOutcome::ContinueWithSoftInterrupt {
injected,
point: "B",
});
}
Ok(NoToolCallOutcome::Break)
}
pub(super) fn take_post_tool_soft_interrupt(&mut self) -> PostToolInterruptOutcome {
let injected = self.inject_soft_interrupts();
if !injected.is_empty() {
PostToolInterruptOutcome::SoftInterrupt {
injected,
point: "D",
}
} else {
PostToolInterruptOutcome::NoInterrupt
}
}
pub(super) fn build_soft_interrupt_events(
injected: Vec<InjectedSoftInterrupt>,
point: &'static str,
tools_skipped: Option<usize>,
) -> Vec<ServerEvent> {
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_EVENTS_BUILD groups={} point={} tools_skipped={:?} total_content_bytes={}",
injected.len(),
point,
tools_skipped,
injected
.iter()
.map(|interrupt| interrupt.content.len())
.sum::<usize>()
));
injected
.into_iter()
.enumerate()
.map(|(idx, interrupt)| ServerEvent::SoftInterruptInjected {
content: interrupt.content,
display_role: soft_interrupt_protocol_display_role(interrupt.source),
point: point.to_string(),
tools_skipped: if idx == 0 { tools_skipped } else { None },
})
.collect()
}
}
@@ -0,0 +1,77 @@
use super::*;
impl Agent {
pub(crate) fn add_message(&mut self, role: Role, content: Vec<ContentBlock>) -> String {
let id = self.session.add_message(role, content);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
if let Some(message) = self.session.messages.last() {
manager.notify_message_added_blocks(&message.content);
} else {
manager.notify_message_added();
}
}
id
}
pub(crate) fn add_message_with_display_role(
&mut self,
role: Role,
content: Vec<ContentBlock>,
display_role: Option<StoredDisplayRole>,
) -> String {
let id = self
.session
.add_message_with_display_role(role, content, display_role);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
if let Some(message) = self.session.messages.last() {
manager.notify_message_added_blocks(&message.content);
} else {
manager.notify_message_added();
}
}
id
}
pub(crate) fn add_message_with_duration(
&mut self,
role: Role,
content: Vec<ContentBlock>,
duration_ms: Option<u64>,
) -> String {
let id = self
.session
.add_message_with_duration(role, content, duration_ms);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
if let Some(message) = self.session.messages.last() {
manager.notify_message_added_blocks(&message.content);
} else {
manager.notify_message_added();
}
}
id
}
pub(crate) fn add_message_ext(
&mut self,
role: Role,
content: Vec<ContentBlock>,
duration_ms: Option<u64>,
token_usage: Option<crate::session::StoredTokenUsage>,
) -> String {
let id = self
.session
.add_message_ext(role, content, duration_ms, token_usage);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
if let Some(message) = self.session.messages.last() {
manager.notify_message_added_blocks(&message.content);
} else {
manager.notify_message_added();
}
}
id
}
}
@@ -0,0 +1,128 @@
use super::Agent;
use crate::logging;
use crate::message::{Message, ToolDefinition};
impl Agent {
pub(super) fn log_prompt_prefix_accounting(
&self,
split: &crate::prompt::SplitSystemPrompt,
tools: &[ToolDefinition],
) {
let system_tokens = split.estimated_tokens();
let tool_tokens = ToolDefinition::aggregate_prompt_token_estimate(tools);
let prefix_tokens = system_tokens + tool_tokens;
logging::info(&format!(
"Prompt prefix estimate: total={} tokens (system={} tools={})",
prefix_tokens, system_tokens, tool_tokens
));
}
pub(super) fn build_memory_prompt_nonblocking_shared(
&self,
messages: std::sync::Arc<[Message]>,
_memory_event_tx: Option<crate::memory::MemoryEventSink>,
) -> Option<crate::memory::PendingMemory> {
if !self.memory_enabled {
return None;
}
let session_id = &self.session.id;
let pending = if crate::message::ends_with_fresh_user_turn(&messages) {
crate::memory::take_pending_memory(session_id)
} else {
None
};
// Use the persistent memory-agent pipeline as the single source of truth.
// Running both this and the legacy MemoryManager background retrieval path
// can prepare overlapping pending prompts for the same turn, which makes
// memory injection feel overly aggressive.
crate::memory_agent::update_context_sync_with_dir(
session_id,
messages,
self.session.working_dir.clone(),
);
pending
}
fn append_current_turn_system_reminder(&self, split: &mut crate::prompt::SplitSystemPrompt) {
let Some(reminder) = self
.current_turn_system_reminder
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return;
};
if !split.dynamic_part.is_empty() {
split.dynamic_part.push_str("\n\n");
}
split.dynamic_part.push_str("# System Reminder\n\n");
split.dynamic_part.push_str(reminder);
}
/// Build split system prompt for better caching
/// Returns static (cacheable) and dynamic (not cached) parts separately
pub(super) fn build_system_prompt_split(
&self,
memory_prompt: Option<&str>,
) -> crate::prompt::SplitSystemPrompt {
if let Some(ref override_prompt) = self.system_prompt_override {
return crate::prompt::SplitSystemPrompt {
static_part: override_prompt.clone(),
dynamic_part: String::new(),
};
}
let skills = self.current_skills_snapshot();
let skill_prompt = self
.active_skill
.as_ref()
.and_then(|name| skills.get(name).map(|skill| skill.get_prompt().to_string()));
let available_skills: Vec<crate::prompt::SkillInfo> = self
.current_skills_snapshot()
.list()
.iter()
.map(|skill| crate::prompt::SkillInfo {
name: skill.name.clone(),
description: skill.description.clone(),
})
.collect();
let working_dir = self
.session
.working_dir
.as_ref()
.map(std::path::PathBuf::from);
let (mut split, _context_info) = crate::prompt::build_system_prompt_split(
skill_prompt.as_deref(),
&available_skills,
self.session.is_canary,
memory_prompt,
working_dir.as_deref(),
);
self.append_current_turn_system_reminder(&mut split);
crate::prompt::append_swarm_effort_directive(
&mut split,
self.provider.reasoning_effort().as_deref(),
);
split
}
/// Non-blocking memory prompt - takes pending result and spawns check for next turn
#[cfg(test)]
pub(super) fn build_memory_prompt_nonblocking(
&self,
messages: &[Message],
_memory_event_tx: Option<crate::memory::MemoryEventSink>,
) -> Option<crate::memory::PendingMemory> {
self.build_memory_prompt_nonblocking_shared(messages.to_vec().into(), _memory_event_tx)
}
}
+240
View File
@@ -0,0 +1,240 @@
use super::*;
impl Agent {
pub fn set_premium_mode(&self, mode: crate::provider::copilot::PremiumMode) {
self.provider.set_premium_mode(mode);
}
pub fn premium_mode(&self) -> crate::provider::copilot::PremiumMode {
self.provider.premium_mode()
}
pub fn provider_fork(&self) -> Arc<dyn Provider> {
self.provider.fork()
}
pub fn provider_handle(&self) -> Arc<dyn Provider> {
Arc::clone(&self.provider)
}
pub fn available_models(&self) -> Vec<&'static str> {
self.provider.available_models()
}
pub fn available_models_for_switching(&self) -> Vec<String> {
self.provider.available_models_for_switching()
}
pub fn available_models_display(&self) -> Vec<String> {
self.provider.available_models_display()
}
pub fn model_routes(&self) -> Vec<crate::provider::ModelRoute> {
self.provider.model_routes()
}
pub fn model_catalog_snapshot(&self) -> jcode_provider_core::ModelCatalogSnapshot {
jcode_provider_core::ModelCatalogSnapshot::new(
Some(self.provider_name()),
Some(self.provider_model()),
self.available_models_display(),
self.model_routes(),
)
}
pub fn registry(&self) -> Registry {
self.registry.clone()
}
pub async fn compaction_mode(&self) -> crate::config::CompactionMode {
self.registry.compaction().read().await.mode()
}
pub async fn set_compaction_mode(&self, mode: crate::config::CompactionMode) -> Result<()> {
let compaction = self.registry.compaction();
let mut manager = compaction.write().await;
manager.set_mode(mode);
Ok(())
}
pub fn provider_messages(&mut self) -> Vec<Message> {
self.session.messages_for_provider()
}
pub fn set_model(&mut self, model: &str) -> Result<()> {
self.set_model_from_provider_state_event(
model,
crate::provider::ProviderModelSelectionSource::User,
)
}
pub fn set_route_selection(
&mut self,
selection: &crate::provider::RouteSelection,
) -> Result<()> {
self.provider.set_route_selection(selection)?;
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());
let event = crate::provider::ProviderStateEvent::selected_model(
crate::provider::ProviderModelSelectionSource::User,
resolved_model,
);
self.provider_runtime_state.apply(event);
self.persist_session_best_effort("route selection");
self.log_env_snapshot("set_route_selection");
Ok(())
}
pub(crate) fn set_model_from_auth(&mut self, model: &str) -> Result<()> {
self.set_model_from_provider_state_event(
model,
crate::provider::ProviderModelSelectionSource::Auth,
)
}
fn set_model_from_provider_state_event(
&mut self,
model: &str,
source: crate::provider::ProviderModelSelectionSource,
) -> Result<()> {
crate::provider::set_model_with_auth_refresh(self.provider.as_ref(), model)?;
let resolved_model = self.provider.model();
self.session.provider_key =
crate::provider::MultiProvider::session_provider_key_after_model_switch(
model,
self.provider.name(),
self.session.provider_key.as_deref(),
);
self.session.model = Some(resolved_model.clone());
let event = crate::provider::ProviderStateEvent::selected_model(source, resolved_model);
self.provider_runtime_state.apply(event);
self.persist_session_best_effort("model selection");
self.log_env_snapshot("set_model");
Ok(())
}
pub(crate) fn provider_model_selection_generation(&self) -> u64 {
self.provider_runtime_state.selection_generation()
}
pub(crate) fn user_selected_provider_model_after(&self, generation: u64) -> bool {
self.provider_runtime_state.user_selected_after(generation)
}
pub fn restore_reasoning_effort_from_session(&mut self) {
if let Some(effort) = self.session.reasoning_effort.clone() {
if let Err(e) = self.provider.set_reasoning_effort(&effort) {
crate::logging::error(&format!(
"Failed to restore session reasoning effort '{}': {}",
effort, e
));
}
} else {
self.session.reasoning_effort = self.provider.reasoning_effort();
}
// Mirror the effort into the deadlock-free side-table so server handlers
// (e.g. the swarm seed handler) can learn this session's effort without
// taking the agent lock.
crate::session_effort::record_session_effort(
&self.session.id,
self.session.reasoning_effort.as_deref(),
);
}
pub fn set_reasoning_effort(&mut self, effort: &str) -> Result<Option<String>> {
self.provider.set_reasoning_effort(effort)?;
let current = self.provider.reasoning_effort();
self.session.reasoning_effort = current.clone();
// Keep the side-table in sync (see `restore_reasoning_effort_from_session`).
crate::session_effort::record_session_effort(&self.session.id, current.as_deref());
self.log_env_snapshot("set_reasoning_effort");
self.session.save()?;
Ok(current)
}
pub fn subagent_model(&self) -> Option<String> {
self.session.subagent_model.clone()
}
pub fn set_subagent_model(&mut self, model: Option<String>) -> Result<()> {
self.session.subagent_model = model;
self.log_env_snapshot("set_subagent_model");
self.session.save()?;
Ok(())
}
pub fn session_provider_key(&self) -> Option<String> {
self.session.provider_key.clone()
}
/// API method/runtime route used to select the active model (e.g.
/// "openai-api", "claude-oauth", "openai-compatible:nvidia-nim"). Spawned
/// swarm agents inherit this so they reconstruct the coordinator's exact
/// auth route instead of falling back to the config default.
pub fn session_route_api_method(&self) -> Option<String> {
self.session.route_api_method.clone()
}
/// The credential the active provider will use for the next request, when
/// the provider distinguishes OAuth (subscription) from API key (cost).
/// Resolved authoritatively here so remote clients can render billing/usage
/// without re-deriving it from the provider name.
pub fn active_resolved_credential(&self) -> Option<jcode_provider_core::ResolvedCredential> {
self.provider.active_resolved_credential()
}
pub fn set_session_provider_key(&mut self, provider_key: Option<String>) {
self.session.provider_key = provider_key;
}
pub fn rename_session_title(&mut self, title: Option<String>) -> Result<String> {
self.session.rename_title(title);
self.log_env_snapshot("rename_session");
self.session.save()?;
Ok(self.session.display_title_or_name().to_string())
}
pub fn autoreview_enabled(&self) -> Option<bool> {
self.session.autoreview_enabled
}
pub fn set_autoreview_enabled(&mut self, enabled: bool) -> Result<()> {
self.session.autoreview_enabled = Some(enabled);
self.log_env_snapshot("set_autoreview_enabled");
self.session.save()?;
Ok(())
}
pub fn autojudge_enabled(&self) -> Option<bool> {
self.session.autojudge_enabled
}
pub fn set_autojudge_enabled(&mut self, enabled: bool) -> Result<()> {
self.session.autojudge_enabled = Some(enabled);
self.log_env_snapshot("set_autojudge_enabled");
self.session.save()?;
Ok(())
}
/// Set the working directory for this session
pub fn set_working_dir(&mut self, dir: &str) {
if self.session.working_dir.as_deref() == Some(dir) {
return;
}
self.session.working_dir = Some(dir.to_string());
self.session.refresh_initial_session_context_message();
self.log_env_snapshot("working_dir");
}
/// Get the working directory for this session
pub fn working_dir(&self) -> Option<&str> {
self.session.working_dir.as_deref()
}
/// Get the stored messages (for transcript export)
pub fn messages(&self) -> &[StoredMessage] {
&self.session.messages
}
}
@@ -0,0 +1,248 @@
use super::*;
impl Agent {
fn parse_text_wrapped_tool_call(
text: &str,
) -> Option<(String, String, serde_json::Value, String)> {
let marker = "to=functions.";
let marker_idx = text.find(marker)?;
let after_marker = &text[marker_idx + marker.len()..];
let mut tool_name_end = 0usize;
for (idx, ch) in after_marker.char_indices() {
if ch.is_ascii_alphanumeric() || ch == '_' {
tool_name_end = idx + ch.len_utf8();
} else {
break;
}
}
if tool_name_end == 0 {
return None;
}
let tool_name = after_marker[..tool_name_end].to_string();
let remaining = &after_marker[tool_name_end..];
let mut fallback: Option<(String, String, serde_json::Value, String)> = None;
for (brace_idx, ch) in remaining.char_indices() {
if ch != '{' {
continue;
}
let slice = &remaining[brace_idx..];
let mut stream =
serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
let parsed = match stream.next() {
Some(Ok(value)) => value,
Some(Err(_)) | None => continue,
};
let consumed = stream.byte_offset();
if !parsed.is_object() {
continue;
}
let prefix = text[..marker_idx].trim_end().to_string();
let suffix = remaining[brace_idx + consumed..].trim().to_string();
if suffix.is_empty() {
return Some((prefix, tool_name.clone(), parsed, suffix));
}
if fallback.is_none() {
fallback = Some((prefix, tool_name.clone(), parsed, suffix));
}
}
fallback
}
pub(super) fn recover_text_wrapped_tool_call(
&self,
text_content: &mut String,
tool_calls: &mut Vec<ToolCall>,
) -> bool {
if !tool_calls.is_empty() || text_content.trim().is_empty() {
return false;
}
let Some((prefix, tool_name, arguments, suffix)) =
Self::parse_text_wrapped_tool_call(text_content)
else {
return false;
};
let mut sanitized = String::new();
if !prefix.is_empty() {
sanitized.push_str(&prefix);
}
if !suffix.is_empty() {
if !sanitized.is_empty() {
sanitized.push('\n');
}
sanitized.push_str(&suffix);
}
*text_content = sanitized;
let call_id = format!("fallback_text_call_{}", id::new_id("call"));
let recovered_total = RECOVERED_TEXT_WRAPPED_TOOL_CALLS
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
logging::warn(&format!(
"[agent] Recovered text-wrapped tool call for '{}' ({}, total={})",
tool_name, call_id, recovered_total
));
let intent = ToolCall::intent_from_input(&arguments);
tool_calls.push(ToolCall {
id: call_id,
name: tool_name,
input: arguments,
intent,
thought_signature: None,
});
true
}
pub(super) fn should_continue_after_stop_reason(stop_reason: &str) -> bool {
let reason = stop_reason.trim().to_ascii_lowercase();
if reason.is_empty() {
return false;
}
if matches!(reason.as_str(), "stop" | "end_turn" | "tool_use") {
return false;
}
reason.contains("incomplete")
|| reason.contains("max_output_tokens")
|| reason.contains("max_tokens")
|| reason.contains("length")
|| reason.contains("trunc")
|| reason.contains("commentary")
}
/// True when the provider's stop reason indicates a model-side
/// guardrail/safety stop (e.g. Anthropic `refusal`), as opposed to a
/// normal end-of-turn or truncation.
pub(crate) fn is_guardrail_stop_reason(stop_reason: Option<&str>) -> bool {
let Some(reason) = stop_reason else {
return false;
};
let reason = reason.trim().to_ascii_lowercase();
matches!(reason.as_str(), "refusal" | "content_filter" | "safety")
|| reason.contains("guardrail")
|| reason.contains("policy_violation")
}
/// 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.
pub(crate) fn provider_guardrail_notice(
stop_reason: Option<&str>,
visible_text_empty: bool,
had_reasoning: bool,
) -> Option<String> {
let guardrail = Self::is_guardrail_stop_reason(stop_reason);
if !guardrail && !visible_text_empty {
return None;
}
let reason_label = stop_reason
.map(str::trim)
.filter(|r| !r.is_empty())
.unwrap_or("unknown");
if guardrail {
return Some(format!(
"Provider guardrail stopped the response (stop_reason: {}). The model declined to answer this request. Rephrasing, narrowing the request, or providing more context may help.",
reason_label
));
}
// Empty visible output with a non-guardrail stop reason: still surface,
// since the user otherwise sees nothing at all.
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.",
reasoning_hint, reason_label
))
}
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.]",
stop_reason.trim()
)
}
pub(crate) fn maybe_continue_incomplete_response(
&mut self,
stop_reason: Option<&str>,
attempts: &mut u32,
) -> Result<bool> {
let Some(stop_reason) = stop_reason
.map(str::trim)
.filter(|reason| !reason.is_empty())
else {
return Ok(false);
};
if !Self::should_continue_after_stop_reason(stop_reason) {
return Ok(false);
}
if *attempts >= Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS {
logging::warn(&format!(
"Response ended with stop_reason='{}' after {} continuation attempts; returning partial output",
stop_reason, attempts
));
return Ok(false);
}
*attempts += 1;
logging::warn(&format!(
"Response ended with stop_reason='{}'; requesting continuation (attempt {}/{})",
stop_reason,
attempts,
Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS
));
self.add_message(
Role::User,
vec![ContentBlock::Text {
text: Self::continuation_prompt_for_stop_reason(stop_reason),
cache_control: None,
}],
);
self.session.save()?;
Ok(true)
}
pub(super) fn filter_truncated_tool_calls(
&mut self,
stop_reason: Option<&str>,
tool_calls: &mut Vec<ToolCall>,
assistant_message_id: Option<&String>,
) {
let stop_reason = stop_reason.unwrap_or("");
if !Self::should_continue_after_stop_reason(stop_reason) {
return;
}
let before = tool_calls.len();
tool_calls.retain(|tc| !tc.input.is_null());
let discarded = before - tool_calls.len();
if discarded > 0 && tool_calls.is_empty() {
logging::warn(&format!(
"Discarded {} tool call(s) with null input (truncated by {}); requesting continuation",
discarded,
if stop_reason.is_empty() {
"unknown"
} else {
stop_reason
}
));
if let Some(msg_id) = assistant_message_id {
self.session.remove_tool_use_blocks(msg_id);
self.persist_session_best_effort("truncated tool-call repair");
}
}
}
}
+168
View File
@@ -0,0 +1,168 @@
use super::*;
impl Agent {
pub fn session_memory_profile_snapshot(
&mut self,
) -> crate::session::SessionMemoryProfileSnapshot {
self.session.memory_profile_snapshot()
}
pub fn message_count(&self) -> usize {
self.session.messages.len()
}
/// Number of model-visible conversation messages (excludes the immutable
/// session-context header and internal system reminders).
pub fn visible_conversation_message_count(&self) -> usize {
self.session.visible_conversation_message_count()
}
/// Role of the most recent model-visible conversation message, if any.
///
/// When this is `User` and the agent is idle, the model still owes a
/// response for that turn (e.g. the turn errored or was interrupted before
/// the assistant replied).
pub fn last_visible_conversation_role(&self) -> Option<Role> {
self.session
.visible_conversation_messages()
.last()
.map(|message| message.role.clone())
}
pub fn last_message_role(&self) -> Option<Role> {
self.session.messages.last().map(|m| m.role.clone())
}
/// Get the text content of the last message (first Text block)
pub fn last_message_text(&self) -> Option<&str> {
self.session.messages.last().and_then(|m| {
m.content.iter().find_map(|block| {
if let ContentBlock::Text { text, .. } = block {
Some(text.as_str())
} else {
None
}
})
})
}
/// Build a transcript string for memory extraction
/// This is a independent method so it can be called before spawning async tasks
pub fn build_transcript_for_extraction(&self) -> String {
let mut transcript = String::new();
for msg in &self.session.messages {
let role = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
transcript.push_str(&format!("**{}:**\n", role));
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
transcript.push_str(text);
transcript.push('\n');
}
ContentBlock::ToolUse { name, .. } => {
transcript.push_str(&format!("[Used tool: {}]\n", name));
}
ContentBlock::ToolResult { content, .. } => {
let preview = if content.len() > 200 {
format!("{}...", crate::util::truncate_str(content, 200))
} else {
content.clone()
};
transcript.push_str(&format!("[Result: {}]\n", preview));
}
ContentBlock::Reasoning { .. }
| ContentBlock::ReasoningTrace { .. }
| ContentBlock::AnthropicThinking { .. }
| ContentBlock::OpenAIReasoning { .. } => {}
ContentBlock::Image { .. } => {
transcript.push_str("[Image]\n");
}
ContentBlock::OpenAICompaction { .. } => {
transcript.push_str("[OpenAI native compaction]\n");
}
}
}
transcript.push('\n');
}
transcript
}
pub fn last_assistant_text(&self) -> Option<String> {
self.session
.messages
.iter()
.rev()
.find(|msg| msg.role == Role::Assistant)
.map(|msg| {
msg.content
.iter()
.filter_map(|c| {
if let ContentBlock::Text { text, .. } = c {
Some(text.clone())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n")
})
}
/// Latest non-empty assistant text added at or after `start_index`.
pub fn latest_assistant_text_after(&self, start_index: usize) -> Option<String> {
self.session
.messages
.iter()
.enumerate()
.rev()
.find_map(|(index, message)| {
if index < start_index || !matches!(&message.role, Role::Assistant) {
return None;
}
let text = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
})
}
pub fn last_upstream_provider(&self) -> Option<String> {
self.last_upstream_provider
.clone()
.or_else(|| self.provider.preferred_provider())
}
pub fn last_connection_type(&self) -> Option<String> {
self.last_connection_type.clone()
}
pub fn last_status_detail(&self) -> Option<String> {
self.last_status_detail.clone()
}
pub fn provider_name(&self) -> String {
// `display_name()` resolves the active runtime profile (e.g. NVIDIA NIM)
// for the OpenRouter slot; for all other providers it equals `name()`.
self.provider.display_name()
}
pub fn provider_model(&self) -> String {
self.provider.model().to_string()
}
/// Get the short/friendly name for this session (e.g., "fox")
pub fn session_short_name(&self) -> Option<&str> {
self.session.short_name.as_deref()
}
}
@@ -0,0 +1,26 @@
use super::STREAM_KEEPALIVE_PONG_ID;
use crate::protocol::ServerEvent;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::{self, MissedTickBehavior};
fn stream_keepalive_interval() -> Duration {
if cfg!(test) {
Duration::from_millis(50)
} else {
Duration::from_secs(30)
}
}
pub(super) fn stream_keepalive_ticker() -> time::Interval {
let interval = stream_keepalive_interval();
let mut ticker = time::interval_at(time::Instant::now() + interval, interval);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
ticker
}
pub(super) fn send_stream_keepalive_mpsc(event_tx: &mpsc::UnboundedSender<ServerEvent>) {
let _ = event_tx.send(ServerEvent::Pong {
id: STREAM_KEEPALIVE_PONG_ID,
});
}
+167
View File
@@ -0,0 +1,167 @@
use crate::message::{ContentBlock, ToolCall};
use crate::tool::ToolOutput;
pub(super) const MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY: usize = 512 * 1024;
pub(super) fn cap_tool_output_for_history(tool_name: &str, mut output: ToolOutput) -> ToolOutput {
if output.output.chars().count() <= MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY {
return output;
}
let original_chars = output.output.chars().count();
let kept = crate::util::truncate_str(&output.output, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY);
output.output = format!(
"{}\n\n[Tool output truncated by jcode: tool `{}` produced {} chars; kept first {} chars to protect the remote protocol, session history, and prompt cache. Redirect large logs to a file and read targeted sections.]",
kept, tool_name, original_chars, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY,
);
output
}
pub(super) fn cap_sdk_tool_content_for_history(tool_name: &str, content: String) -> String {
if content.chars().count() <= MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY {
return content;
}
let original_chars = content.chars().count();
let kept = crate::util::truncate_str(&content, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY);
format!(
"{}\n\n[Tool output truncated by jcode: tool `{}` produced {} chars; kept first {} chars to protect the remote protocol, session history, and prompt cache. Redirect large logs to a file and read targeted sections.]",
kept, tool_name, original_chars, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY,
)
}
/// Build rendered side-pane images from a tool output's attached images.
///
/// This mirrors how `render_messages_and_images` derives images from persisted
/// session history (source = ToolResult), so live-streamed images match what a
/// later History reload would produce. `tool_name` and `tool_input` provide the
/// label fallback (e.g. the `read` tool's `file_path`); `tool_call_id` anchors
/// the image to its tool message in the transcript.
pub(super) fn tool_output_side_pane_images(
tool_call_id: &str,
tool_name: &str,
tool_input: &serde_json::Value,
output: &ToolOutput,
) -> Vec<jcode_session_types::RenderedImage> {
if output.images.is_empty() {
return Vec::new();
}
let fallback_label = tool_input
.get("file_path")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
output
.images
.iter()
.map(|img| jcode_session_types::RenderedImage {
media_type: img.media_type.clone(),
data: img.data.clone(),
label: img
.label
.as_ref()
.map(|label| label.trim().to_string())
.filter(|label| !label.is_empty())
.or_else(|| fallback_label.clone()),
source: jcode_session_types::RenderedImageSource::ToolResult {
tool_name: tool_name.to_string(),
},
anchor: Some(jcode_session_types::RenderedImageAnchor::ToolCall {
id: tool_call_id.to_string(),
}),
})
.collect()
}
pub(super) fn tool_output_to_content_blocks(
tool_use_id: String,
output: ToolOutput,
) -> Vec<ContentBlock> {
let mut blocks = vec![ContentBlock::ToolResult {
tool_use_id,
content: output.output,
is_error: None,
}];
for img in output.images {
blocks.push(ContentBlock::Image {
media_type: img.media_type,
data: img.data,
});
if let Some(label) = img.label.filter(|label| !label.trim().is_empty()) {
blocks.push(ContentBlock::Text {
text: format!(
"[Attached image associated with the preceding tool result: {}]",
label
),
cache_control: None,
});
}
}
blocks
}
pub(super) fn print_tool_summary(tool: &ToolCall) {
match tool.name.as_str() {
"bash" => {
if let Some(cmd) = tool.input.get("command").and_then(|v| v.as_str()) {
let short = if cmd.len() > 60 {
format!("{}...", crate::util::truncate_str(cmd, 60))
} else {
cmd.to_string()
};
println!("$ {}", short);
}
}
"read" | "write" | "edit" => {
if let Some(path) = tool.input.get("file_path").and_then(|v| v.as_str()) {
println!("{}", path);
}
}
"glob" | "grep" => {
if let Some(pattern) = tool.input.get("pattern").and_then(|v| v.as_str()) {
println!("'{}'", pattern);
}
}
"ls" => {
let path = tool
.input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
println!("{}", path);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cap_tool_output_leaves_small_output_unchanged() {
let output = ToolOutput::new("short output");
let capped = cap_tool_output_for_history("bash", output.clone());
assert_eq!(capped.output, output.output);
}
#[test]
fn cap_tool_output_adds_visible_truncation_notice() {
let output = ToolOutput::new("x".repeat(MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 10));
let capped = cap_tool_output_for_history("bash", output);
assert!(capped.output.len() < MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 1_000);
assert!(capped.output.contains("Tool output truncated by jcode"));
assert!(capped.output.contains("tool `bash` produced"));
assert!(capped.output.contains("Redirect large logs to a file"));
}
#[test]
fn cap_sdk_tool_content_adds_same_notice() {
let capped = cap_sdk_tool_content_for_history(
"custom",
"y".repeat(MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 10),
);
assert!(capped.contains("Tool output truncated by jcode"));
assert!(capped.contains("tool `custom` produced"));
}
}
@@ -0,0 +1,917 @@
use super::*;
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(
Role::User,
vec![ContentBlock::Text {
text: user_message.to_string(),
cache_control: None,
}],
);
self.session.save()?;
if trace_enabled() {
eprintln!("[trace] session_id {}", self.session.id);
}
let _ = self.run_turn(true).await?;
Ok(())
}
pub async fn run_once_capture(&mut self, user_message: &str) -> Result<String> {
self.add_message(
Role::User,
vec![ContentBlock::Text {
text: user_message.to_string(),
cache_control: None,
}],
);
self.session.save()?;
if trace_enabled() {
eprintln!("[trace] session_id {}", self.session.id);
}
self.run_turn(false).await
}
/// Run one conversation turn with streaming events via mpsc channel (per-client)
pub async fn run_once_streaming_mpsc(
&mut self,
user_message: &str,
images: Vec<(String, String)>,
system_reminder: Option<String>,
event_tx: mpsc::UnboundedSender<ServerEvent>,
) -> Result<()> {
// Inject any pending notifications before the user message
let alerts = self.take_alerts();
if !alerts.is_empty() {
let alert_text = format!(
"[NOTIFICATION]\nYou received {} notification(s) from other agents working in this codebase:\n\n{}\n\nUse the communicate tool to coordinate with other agents (prefer dm; broadcast reaches only your spawned subtree).",
alerts.len(),
alerts.join("\n\n---\n\n")
);
self.add_message(
Role::User,
vec![ContentBlock::Text {
text: alert_text,
cache_control: None,
}],
);
}
self.current_turn_system_reminder =
system_reminder.filter(|value| !value.trim().is_empty());
let mut blocks: Vec<ContentBlock> = images
.into_iter()
.map(|(media_type, data)| ContentBlock::Image { media_type, data })
.collect();
blocks.push(ContentBlock::Text {
text: user_message.to_string(),
cache_control: None,
});
if blocks.len() > 1 {
crate::logging::info(&format!(
"Agent received message with {} image(s)",
blocks.len() - 1
));
}
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
}
/// Fire the `turn_start` observer hook when a turn begins, before the model
/// starts generating (and before the first `pre_tool`). This lets external
/// integrations (terminal multiplexers, status bars) detect that the agent
/// is actively working during the otherwise-invisible window between prompt
/// submission and the first tool call. No-op (without building the payload)
/// when the hook is not configured.
fn fire_turn_start_hook(&self, source: &str) {
if !crate::hooks::hook_configured("turn_start") {
return;
}
let mut event = crate::hooks::HookEvent::new("turn_start")
.session_id(self.session.id.clone())
.field("MODEL", self.provider_model())
.field("SOURCE", source.to_string());
if let Some(cwd) = self.working_dir() {
event = event.cwd(cwd);
}
crate::hooks::dispatch_observer(event);
}
/// Fire the `turn_end` observer hook with turn outcome metadata.
/// No-op (without building the payload) when the hook is not configured.
fn fire_turn_end_hook(
&self,
result: &Result<()>,
started_at: Instant,
start_message_index: usize,
) {
if !crate::hooks::hook_configured("turn_end") {
return;
}
let status = if result.is_ok() { "ok" } else { "error" };
let mut event = crate::hooks::HookEvent::new("turn_end")
.session_id(self.session.id.clone())
.field("STATUS", status)
.field("DURATION_MS", started_at.elapsed().as_millis().to_string())
.field("MODEL", self.provider_model());
if let Some(cwd) = self.working_dir() {
event = event.cwd(cwd);
}
if let Some(text) = self.latest_assistant_text_after(start_message_index) {
const LAST_TEXT_LIMIT: usize = 4000;
let snippet: String = text.chars().take(LAST_TEXT_LIMIT).collect();
event = event.field("LAST_ASSISTANT_TEXT", snippet);
}
if let Err(error) = result {
const ERROR_LIMIT: usize = 1000;
let message: String = error.to_string().chars().take(ERROR_LIMIT).collect();
event = event.field("ERROR", message);
}
crate::hooks::dispatch_observer(event);
}
/// Clear conversation history
pub fn clear(&mut self) {
let preserve_canary = self.session.is_canary;
let preserve_testing_build = self.session.testing_build.clone();
let preserve_debug = self.session.is_debug;
let preserve_working_dir = self.session.working_dir.clone();
self.session.mark_closed();
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.is_canary = preserve_canary;
new_session.testing_build = preserve_testing_build;
new_session.is_debug = preserve_debug;
new_session.working_dir = preserve_working_dir;
new_session.ensure_initial_session_context_message();
self.session = new_session;
self.reset_runtime_state_for_session_change();
self.provider_session_id = None;
self.seed_compaction_from_session();
}
/// Clear provider session so the next turn sends full context.
pub fn reset_provider_session(&mut self) {
self.provider_session_id = None;
self.session.provider_session_id = None;
self.persist_session_best_effort("provider session reset");
}
/// Rewind the conversation to a 1-based visible transcript message index.
///
/// The index is interpreted against the same rendered transcript the TUI
/// numbers in `/rewind` (user/assistant entries only, tool cards and
/// system notices excluded). Mapping through raw stored messages instead
/// would count tool-result messages the UI never numbers, sending
/// `/rewind N` far earlier than the on-screen message N (issue #432).
///
/// Provider-side resumable sessions are reset so the next request sends the
/// truncated context from scratch instead of continuing from a stale upstream
/// conversation.
pub fn rewind_to_message(&mut self, message_index: usize) -> Result<usize, String> {
let targets = self.session.rewind_target_stored_indices();
let message_count = targets.len();
if message_index == 0 || message_index > message_count {
return Err(format!(
"Invalid message number: {}. Valid range: 1-{}",
message_index, message_count
));
}
let stored_len = targets[message_index - 1] + 1;
let removed = message_count - message_index;
self.rewind_undo_snapshot = Some(RewindUndoSnapshot {
messages: self.session.messages.clone(),
provider_session_id: self.provider_session_id.clone(),
session_provider_session_id: self.session.provider_session_id.clone(),
visible_message_count: message_count,
});
self.session.truncate_messages(stored_len);
self.session.updated_at = chrono::Utc::now();
self.provider_session_id = None;
self.session.provider_session_id = None;
self.cache_tracker.reset();
self.locked_tools = None;
self.reset_tool_output_tracking();
self.persist_session_best_effort("conversation rewind");
Ok(removed)
}
pub fn undo_rewind(&mut self) -> Result<usize, String> {
let Some(snapshot) = self.rewind_undo_snapshot.take() else {
return Err("No rewind to undo.".to_string());
};
let current_count = self.session.rewind_target_count();
let restored = snapshot.visible_message_count.saturating_sub(current_count);
self.session.replace_messages(snapshot.messages);
self.provider_session_id = snapshot.provider_session_id;
self.session.provider_session_id = snapshot.session_provider_session_id;
self.session.updated_at = chrono::Utc::now();
self.cache_tracker.reset();
self.locked_tools = None;
self.reset_tool_output_tracking();
self.persist_session_best_effort("conversation rewind undo");
Ok(restored)
}
/// Unlock the tool list so the next API request picks up any new tools.
/// Called after MCP reload or when the user explicitly wants new tools.
pub fn unlock_tools(&mut self) {
if self.locked_tools.is_some() {
logging::info("Tool list unlocked — next request will pick up current tools");
self.locked_tools = None;
self.cache_tracker.reset();
}
// Allow the late-MCP-registration recheck to fire once for the next
// snapshot (e.g. after an explicit `mcp` reload).
self.mcp_late_register_resolved = false;
}
/// Unlock tools if a tool execution may have changed the registry
/// (e.g., mcp connect/disconnect/reload)
pub(super) fn unlock_tools_if_needed(&mut self, tool_name: &str) {
if tool_name == "mcp" {
self.unlock_tools();
}
}
pub fn is_canary(&self) -> bool {
self.session.is_canary
}
pub fn is_debug(&self) -> bool {
self.session.is_debug
}
pub fn set_canary(&mut self, build_hash: &str) {
self.session.set_canary(build_hash);
if let Err(err) = self.session.save() {
logging::error(&format!("Failed to persist canary session state: {}", err));
}
}
/// Mark this session as a debug/test session
/// Set a custom system prompt override (used by ambient mode).
/// When set, this replaces the normal system prompt entirely.
pub fn set_system_prompt(&mut self, prompt: &str) {
self.system_prompt_override = Some(prompt.to_string());
}
pub fn set_debug(&mut self, is_debug: bool) {
self.session.set_debug(is_debug);
if let Err(err) = self.session.save() {
logging::error(&format!("Failed to persist debug session state: {}", err));
}
}
/// Enable or disable memory features for this session.
pub fn set_memory_enabled(&mut self, enabled: bool) {
self.memory_enabled = enabled;
if !enabled {
crate::memory::clear_pending_memory(&self.session.id);
}
}
/// Mark this session as an inline swarm worker. When enabled, the streaming
/// loop publishes a throttled output tail to the global bus so a
/// coordinator can render a live inline gallery viewport for it.
pub fn set_inline_output_tap(&mut self, enabled: bool) {
self.inline_output_tap = enabled;
}
/// Whether this session streams an inline output tail to the bus.
pub(crate) fn inline_output_tap(&self) -> bool {
self.inline_output_tap
}
/// Publish the current rolling activity tail to the bus for the
/// coordinator's inline gallery. No-op unless the inline tap is enabled.
pub(crate) fn publish_inline_tail(&self) {
if !self.inline_output_tap {
return;
}
crate::bus::Bus::global().publish(crate::bus::BusEvent::SwarmOutputTail(
crate::bus::SwarmOutputTail {
session_id: self.session.id.clone(),
tail: self.inline_tail.render(),
},
));
}
/// Check whether memory features are enabled for this session.
pub fn memory_enabled(&self) -> bool {
self.memory_enabled
}
/// Set the stdin request channel for interactive stdin forwarding
pub fn set_stdin_request_tx(
&mut self,
tx: tokio::sync::mpsc::UnboundedSender<crate::tool::StdinInputRequest>,
) {
self.stdin_request_tx = Some(tx);
}
pub(super) async fn tool_definitions(&mut self) -> Vec<ToolDefinition> {
if self.session.is_canary {
self.registry.register_selfdev_tools().await;
}
// Return locked tools if available (prevents cache invalidation from
// tools arriving asynchronously after the first API request).
//
// Exception: MCP servers connect on a background task and register
// `mcp__*` tools seconds after the session starts — typically *after*
// the first turn has already locked the snapshot. We deliberately do
// NOT block the first turn on MCP connection: servers can be slow or
// hang, and we want the user to be able to talk to the agent the moment
// the session spawns. The price is that the first locked snapshot is
// missing MCP tools, and the only other unlock path fires when the model
// calls the `mcp` management tool — which it cannot do without first
// seeing MCP tools (#206).
//
// So, exactly once per locked snapshot, if MCP tools have since appeared
// in the registry, we rebuild. This is a single intentional provider
// 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.
if let Some(ref locked) = self.locked_tools {
if self.mcp_late_register_resolved {
return locked.clone();
}
if self.registry_has_new_mcp_tools(locked).await {
logging::info(
"MCP tools registered after first turn locked the tool snapshot — \
rebuilding once to expose them. This is one intentional prompt-cache \
miss; we accept it so the agent is reachable immediately at spawn \
instead of blocking on MCP connection (#206).",
);
// Latch the one-shot guard and drop the stale snapshot directly.
// We intentionally do NOT call `unlock_tools()` here, because that
// re-arms the guard (it is the explicit-reload path) and would let
// the recheck fire again on every later turn.
self.mcp_late_register_resolved = true;
self.locked_tools = None;
self.cache_tracker.reset();
} else {
// No MCP tools have appeared. They may still be connecting, so
// leave the guard unset and re-check on the next turn. Once they
// appear (or never do, after the registry settles) we stop.
return locked.clone();
}
}
let tools = self.build_filtered_tool_definitions().await;
// Lock the tool list to prevent cache invalidation when more tools
// arrive asynchronously mid-session.
logging::info(&format!(
"Locking tool list at {} tools for cache stability",
tools.len()
));
self.locked_tools = Some(tools.clone());
tools
}
/// Build the agent's tool definitions from the registry, applying the
/// session's `allowed_tools`, `disabled_tools`, and self-dev filters.
async fn build_filtered_tool_definitions(&self) -> Vec<ToolDefinition> {
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
}
/// Tailor the `selfdev` tool definition to the session 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) {
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);
}
}
}
/// Returns true if the registry contains `mcp__*` tools (subject to the
/// session's `allowed_tools` filter) that are not present in the currently
/// locked snapshot. Used to detect the async MCP-registration race (#206).
async fn registry_has_new_mcp_tools(&self, locked: &[ToolDefinition]) -> bool {
let registry_names = self.registry.tool_names().await;
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)
&& !locked.iter().any(|t| &t.name == name)
})
}
pub async fn tool_names(&self) -> Vec<String> {
self.tool_definitions_for_debug()
.await
.into_iter()
.map(|tool| tool.name)
.collect()
}
/// Get full tool definitions for debug introspection (bypasses lock)
pub async fn tool_definitions_for_debug(&self) -> Vec<crate::message::ToolDefinition> {
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
}
pub async fn execute_tool(
&self,
name: &str,
input: serde_json::Value,
) -> Result<crate::tool::ToolOutput> {
self.validate_tool_allowed(name)?;
let call_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("debug-{}", d.as_millis()))
.unwrap_or_else(|_| "debug".to_string());
let ctx = ToolContext {
session_id: self.session.id.clone(),
message_id: self.session.id.clone(),
tool_call_id: call_id,
working_dir: self.working_dir().map(PathBuf::from),
stdin_request_tx: self.stdin_request_tx.clone(),
graceful_shutdown_signal: Some(self.graceful_shutdown.clone()),
execution_mode: ToolExecutionMode::Direct,
};
self.registry.execute(name, input, ctx).await
}
pub fn add_manual_tool_use(
&mut self,
tool_call_id: String,
tool_name: String,
input: serde_json::Value,
) -> Result<String> {
let message_id = self.add_message(
Role::Assistant,
vec![ContentBlock::ToolUse {
id: tool_call_id,
name: tool_name,
input,
thought_signature: None,
}],
);
self.session.save()?;
Ok(message_id)
}
pub fn add_manual_tool_result(
&mut self,
tool_call_id: String,
output: crate::tool::ToolOutput,
duration_ms: u64,
) -> Result<()> {
let blocks = tool_output_to_content_blocks(tool_call_id, output);
self.add_message_with_duration(Role::User, blocks, Some(duration_ms));
self.session.save()?;
Ok(())
}
pub fn add_manual_tool_error(
&mut self,
tool_call_id: String,
error: String,
duration_ms: u64,
) -> Result<()> {
self.add_message_with_duration(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: tool_call_id,
content: error,
is_error: Some(true),
}],
Some(duration_ms),
);
self.session.save()?;
Ok(())
}
pub(super) fn validate_tool_allowed(&self, name: &str) -> Result<()> {
if let Some(allowed) = self.allowed_tools.as_ref()
&& !allowed.contains(name)
{
return Err(anyhow::anyhow!("Tool '{}' is not allowed", name));
}
if self.disabled_tools.contains(name) {
return Err(anyhow::anyhow!("Tool '{}' is disabled", name));
}
Ok(())
}
/// Restore a session by ID (loads from disk)
pub fn restore_session(&mut self, session_id: &str) -> Result<SessionStatus> {
self.restore_session_with_working_dir(session_id, None)
}
pub(crate) fn restore_session_with_working_dir(
&mut self,
session_id: &str,
working_dir: Option<&str>,
) -> Result<SessionStatus> {
let restore_start = Instant::now();
let load_start = Instant::now();
let mut session = Session::load(session_id)?;
if let Some(working_dir) = working_dir {
session.working_dir = Some(working_dir.to_string());
session.refresh_initial_session_context_message();
}
let load_ms = load_start.elapsed().as_millis();
logging::info(&format!(
"Restoring session '{}' with {} messages, provider_session_id: {:?}, status: {}",
session_id,
session.messages.len(),
session.provider_session_id,
session.status.display()
));
let previous_status = session.status.clone();
let assign_start = Instant::now();
let previous_session_id = self.session.id.clone();
// 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.session.id,
self.allowed_tools.clone(),
self.disabled_tools.clone(),
);
let assign_ms = assign_start.elapsed().as_millis();
let reset_start = Instant::now();
self.reset_runtime_state_for_session_change();
let restored_soft_interrupts = self.restore_persisted_soft_interrupts();
let reset_ms = reset_start.elapsed().as_millis();
let model_start = Instant::now();
if let Some(model) = self.session.model.clone() {
let model_request =
crate::provider::MultiProvider::model_switch_request_for_session_route(
&model,
self.session.provider_key.as_deref(),
self.session.route_api_method.as_deref(),
);
if let Err(e) =
crate::provider::set_model_with_auth_refresh(self.provider.as_ref(), &model_request)
{
logging::error(&format!(
"Failed to restore session model '{}' via '{}': {}",
model, model_request, e
));
}
} else {
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();
let mark_active_ms = mark_active_start.elapsed().as_millis();
self.sync_memory_dedup_state_from_session();
logging::info(&format!(
"restore_session: loaded session {} with {} messages, calling seed_compaction",
session_id,
self.session.messages.len()
));
let compaction_start = Instant::now();
self.seed_compaction_from_session();
let compaction_ms = compaction_start.elapsed().as_millis();
let env_snapshot_start = Instant::now();
self.log_env_snapshot("resume");
let env_snapshot_ms = env_snapshot_start.elapsed().as_millis();
self.fire_session_lifecycle_hook("session_start", "resume");
let save_start = Instant::now();
if let Err(err) = self.session.save() {
logging::error(&format!(
"Failed to persist resumed session state for {}: {}",
session_id, err
));
}
let save_ms = save_start.elapsed().as_millis();
logging::info(&format!(
"[TIMING] restore_session: session={}, messages={}, restored_soft_interrupts={}, load={}ms, assign={}ms, reset={}ms, model={}ms, mark_active={}ms, compaction={}ms, env_snapshot={}ms, save={}ms, total={}ms",
session_id,
self.session.messages.len(),
restored_soft_interrupts,
load_ms,
assign_ms,
reset_ms,
model_ms,
mark_active_ms,
compaction_ms,
env_snapshot_ms,
save_ms,
restore_start.elapsed().as_millis(),
));
logging::info(&format!(
"Session restored: {} messages in session",
self.session.messages.len()
));
Ok(previous_status)
}
/// Get conversation history for sync
pub fn get_history(&self) -> Vec<HistoryMessage> {
crate::session::render_messages(&self.session)
.into_iter()
.map(|msg| HistoryMessage {
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
None
} else {
Some(msg.tool_calls)
},
tool_data: msg.tool_data,
})
.collect()
}
pub fn get_history_and_rendered_images(
&self,
) -> (Vec<HistoryMessage>, Vec<crate::session::RenderedImage>) {
let (messages, images) = crate::session::render_messages_and_images(&self.session);
let history = messages
.into_iter()
.map(|msg| HistoryMessage {
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
None
} else {
Some(msg.tool_calls)
},
tool_data: msg.tool_data,
})
.collect();
(history, images)
}
pub fn get_history_and_rendered_images_with_compacted_history(
&self,
compacted_history_visible: usize,
) -> (
Vec<HistoryMessage>,
Vec<crate::session::RenderedImage>,
Option<crate::session::RenderedCompactedHistoryInfo>,
) {
let (messages, images, compacted_info) =
crate::session::render_messages_and_images_with_compacted_history(
&self.session,
compacted_history_visible,
);
let history = messages
.into_iter()
.map(|msg| HistoryMessage {
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
None
} else {
Some(msg.tool_calls)
},
tool_data: msg.tool_data,
})
.collect();
(history, images, compacted_info)
}
pub fn get_tool_call_summaries(&self, limit: usize) -> Vec<crate::protocol::ToolCallSummary> {
crate::session::summarize_tool_calls(&self.session, limit)
}
/// Start an interactive REPL
pub async fn repl(&mut self) -> Result<()> {
println!("J-Code - Coding Agent");
println!("Type your message, or 'quit' to exit.");
// Show available skills
let skills = self.current_skills_snapshot();
let skill_list = skills.list();
if !skill_list.is_empty() {
println!(
"Available skills: {}",
skill_list
.iter()
.map(|s| format!("/{}", s.name))
.collect::<Vec<_>>()
.join(", ")
);
}
println!();
loop {
print!("> ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.is_empty() {
continue;
}
if input == "quit" || input == "exit" {
break;
}
if input == "clear" {
self.clear();
println!("Conversation cleared.");
continue;
}
// Check for skill invocation
if let Some(invocation) = SkillRegistry::parse_invocation(input) {
if let Some(skill) = skills.get(invocation.name) {
println!("Activating skill: {}", skill.name);
println!("{}\n", skill.description);
self.active_skill = Some(invocation.name.to_string());
if let Some(prompt) = invocation.prompt {
if let Err(e) = self.run_once(prompt).await {
eprintln!("\nError: {}\n", e);
}
println!();
}
continue;
} else {
println!("Unknown skill: /{}", invocation.name);
println!(
"Available: {}",
skills
.list()
.iter()
.map(|s| format!("/{}", s.name))
.collect::<Vec<_>>()
.join(", ")
);
continue;
}
}
if let Err(e) = self.run_once(input).await {
eprintln!("\nError: {}\n", e);
}
println!();
}
// Extract memories from session before exiting
self.extract_session_memories().await;
Ok(())
}
/// Extract memories from the session transcript
/// Returns the number of memories extracted, or 0 if none/skipped
pub async fn extract_session_memories(&self) -> usize {
if !self.memory_enabled {
return 0;
}
// Need at least 4 messages for meaningful extraction
if self.session.messages.len() < 4 {
return 0;
}
logging::info(&format!(
"Extracting memories from {} messages",
self.session.messages.len()
));
// Build transcript
let mut transcript = String::new();
for msg in &self.session.messages {
let role = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
transcript.push_str(&format!("**{}:**\n", role));
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
transcript.push_str(text);
transcript.push('\n');
}
ContentBlock::ToolUse { name, .. } => {
transcript.push_str(&format!("[Used tool: {}]\n", name));
}
ContentBlock::ToolResult { content, .. } => {
let preview = if content.len() > 200 {
format!("{}...", crate::util::truncate_str(content, 200))
} else {
content.clone()
};
transcript.push_str(&format!("[Result: {}]\n", preview));
}
ContentBlock::Reasoning { .. }
| ContentBlock::ReasoningTrace { .. }
| ContentBlock::AnthropicThinking { .. }
| ContentBlock::OpenAIReasoning { .. } => {}
ContentBlock::Image { .. } => {
transcript.push_str("[Image]\n");
}
ContentBlock::OpenAICompaction { .. } => {
transcript.push_str("[OpenAI native compaction]\n");
}
}
}
transcript.push('\n');
}
if !crate::memory::memory_llm_judge_available() {
logging::info("Memory extraction skipped: LLM judge unavailable");
return 0;
}
// Extract using sidecar
let sidecar = crate::sidecar::Sidecar::new();
match sidecar.extract_memories(&transcript).await {
Ok(extracted) if !extracted.is_empty() => {
let manager = self
.session
.working_dir
.as_deref()
.map(|dir| crate::memory::MemoryManager::new().with_project_dir(dir))
.unwrap_or_default();
let mut stored_count = 0;
for memory in &extracted {
let category = crate::memory::MemoryCategory::from_extracted(&memory.category);
let trust = match memory.trust.as_str() {
"high" => crate::memory::TrustLevel::High,
"low" => crate::memory::TrustLevel::Low,
_ => crate::memory::TrustLevel::Medium,
};
let entry = crate::memory::MemoryEntry::new(category, &memory.content)
.with_source(&self.session.id)
.with_trust(trust);
if manager.remember_project(entry).is_ok() {
stored_count += 1;
}
}
if stored_count > 0 {
logging::info(&format!("Extracted {} memories from session", stored_count));
}
stored_count
}
Ok(_) => 0,
Err(e) => {
logging::info(&format!("Memory extraction skipped: {}", e));
0
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
use crate::session::GitState;
use std::path::Path;
use std::process::Command;
pub(super) fn trace_enabled() -> bool {
match std::env::var("JCODE_TRACE") {
Ok(value) => {
let value = value.trim();
!value.is_empty() && value != "0" && value.to_lowercase() != "false"
}
Err(_) => false,
}
}
pub(super) fn git_state_for_dir(dir: &Path) -> Option<GitState> {
let root = git_output(dir, &["rev-parse", "--show-toplevel"])?;
let head = git_output(dir, &["rev-parse", "HEAD"]);
let branch = git_output(dir, &["rev-parse", "--abbrev-ref", "HEAD"]);
let dirty = git_output(dir, &["status", "--porcelain"]).map(|out| !out.is_empty());
Some(GitState {
root,
head,
branch,
dirty,
})
}
fn git_output(dir: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
mod directives;
mod manager;
mod paths;
mod persistence;
mod prompt;
pub mod runner;
pub mod scheduler;
pub use directives::{
UserDirective, add_directive, has_pending_directives, load_directives, take_pending_directives,
};
pub use manager::AmbientManager;
pub use persistence::{AmbientLock, ScheduledQueue};
#[cfg(test)]
pub(crate) use prompt::format_duration_rough;
pub use prompt::{
MemoryGraphHealth, RecentSessionInfo, ResourceBudget, build_ambient_system_prompt,
format_minutes_human, format_scheduled_session_message, gather_feedback_memories,
gather_memory_graph_health, gather_recent_sessions,
};
use crate::storage;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// Context passed from the ambient runner to a visible TUI cycle.
/// Saved to `~/.jcode/ambient/visible_cycle.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibleCycleContext {
pub system_prompt: String,
pub initial_message: String,
}
impl VisibleCycleContext {
pub fn context_path() -> Result<PathBuf> {
Ok(storage::jcode_dir()?
.join("ambient")
.join("visible_cycle.json"))
}
pub fn save(&self) -> Result<()> {
let path = Self::context_path()?;
if let Some(parent) = path.parent() {
storage::ensure_dir(parent)?;
}
storage::write_json(&path, self)
}
pub fn load() -> Result<Self> {
let path = Self::context_path()?;
storage::read_json(&path)
}
pub fn result_path() -> Result<PathBuf> {
Ok(storage::jcode_dir()?
.join("ambient")
.join("cycle_result.json"))
}
}
/// Ambient mode status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub enum AmbientStatus {
#[default]
Idle,
Running {
detail: String,
},
Scheduled {
next_wake: DateTime<Utc>,
},
Paused {
reason: String,
},
Disabled,
}
/// Priority for scheduled items
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum Priority {
Low,
Normal,
High,
}
/// Where a scheduled task should be delivered when it becomes due.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ScheduleTarget {
/// Wake the ambient agent and hand it the queued task.
#[default]
Ambient,
/// Deliver the reminder back into a specific interactive session.
Session { session_id: String },
/// Spawn a single new session derived from the originating session.
Spawn { parent_session_id: String },
}
impl ScheduleTarget {
pub fn is_direct_delivery(&self) -> bool {
matches!(self, Self::Session { .. } | Self::Spawn { .. })
}
}
/// A scheduled ambient task
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledItem {
pub id: String,
pub scheduled_for: DateTime<Utc>,
pub context: String,
pub priority: Priority,
#[serde(default)]
pub target: ScheduleTarget,
pub created_by_session: String,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relevant_files: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
}
/// Persistent ambient state
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AmbientState {
pub status: AmbientStatus,
pub last_run: Option<DateTime<Utc>>,
pub last_summary: Option<String>,
pub last_compactions: Option<u32>,
pub last_memories_modified: Option<u32>,
pub total_cycles: u64,
}
/// Result from an ambient cycle
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmbientCycleResult {
pub summary: String,
pub memories_modified: u32,
pub compactions: u32,
pub proactive_work: Option<String>,
pub next_schedule: Option<ScheduleRequest>,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub status: CycleStatus,
/// Full conversation transcript (markdown) for email notifications
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CycleStatus {
Complete,
Interrupted,
Incomplete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleRequest {
pub wake_in_minutes: Option<u32>,
pub wake_at: Option<DateTime<Utc>>,
pub context: String,
pub priority: Priority,
#[serde(default)]
pub target: ScheduleTarget,
#[serde(default)]
pub created_by_session: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relevant_files: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "ambient_tests.rs"]
mod ambient_tests;
@@ -0,0 +1,76 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use super::paths::ambient_dir;
use crate::storage;
// ---------------------------------------------------------------------------
// User Directives (from email replies)
// ---------------------------------------------------------------------------
/// A user directive received via email reply to an ambient cycle notification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserDirective {
pub id: String,
pub text: String,
pub received_at: DateTime<Utc>,
pub in_reply_to_cycle: String,
pub consumed: bool,
}
fn directives_path() -> Result<PathBuf> {
Ok(ambient_dir()?.join("directives.json"))
}
pub fn load_directives() -> Vec<UserDirective> {
directives_path()
.ok()
.and_then(|p| {
if p.exists() {
storage::read_json(&p).ok()
} else {
None
}
})
.unwrap_or_default()
}
fn save_directives(directives: &[UserDirective]) -> Result<()> {
storage::write_json(&directives_path()?, directives)
}
/// Store a new directive from an email reply.
pub fn add_directive(text: String, in_reply_to: String) -> Result<()> {
let mut directives = load_directives();
directives.push(UserDirective {
id: format!("dir_{:08x}", rand::random::<u32>()),
text,
received_at: Utc::now(),
in_reply_to_cycle: in_reply_to,
consumed: false,
});
save_directives(&directives)
}
/// Take all unconsumed directives, marking them as consumed.
pub fn take_pending_directives() -> Vec<UserDirective> {
let mut all = load_directives();
let pending: Vec<_> = all.iter().filter(|d| !d.consumed).cloned().collect();
if pending.is_empty() {
return pending;
}
for d in &mut all {
if !d.consumed {
d.consumed = true;
}
}
let _ = save_directives(&all);
pending
}
/// Check if there are any unconsumed directives.
pub fn has_pending_directives() -> bool {
load_directives().iter().any(|d| !d.consumed)
}
@@ -0,0 +1,110 @@
use anyhow::Result;
use chrono::Utc;
use super::paths::{ambient_dir, queue_path, transcripts_dir};
use super::{
AmbientCycleResult, AmbientState, AmbientStatus, ScheduleRequest, ScheduledItem, ScheduledQueue,
};
use crate::config::config;
// ---------------------------------------------------------------------------
// AmbientManager
// ---------------------------------------------------------------------------
pub struct AmbientManager {
state: AmbientState,
queue: ScheduledQueue,
}
impl AmbientManager {
pub fn new() -> Result<Self> {
// Ensure storage layout exists
let _ = ambient_dir()?;
let _ = transcripts_dir()?;
let state = AmbientState::load()?;
let queue = ScheduledQueue::load(queue_path()?);
Ok(Self { state, queue })
}
pub fn is_enabled() -> bool {
config().ambient.enabled
}
/// Check whether it's time to run a cycle based on current state and queue.
pub fn should_run(&self) -> bool {
if !Self::is_enabled() {
return false;
}
match &self.state.status {
AmbientStatus::Disabled | AmbientStatus::Paused { .. } => false,
AmbientStatus::Running { .. } => false, // already running
AmbientStatus::Idle => true,
AmbientStatus::Scheduled { next_wake } => Utc::now() >= *next_wake,
}
}
pub fn record_cycle_result(&mut self, result: AmbientCycleResult) -> Result<()> {
self.state.record_cycle(&result);
self.state.save()?;
// If the cycle produced a schedule request, enqueue it
if let Some(ref req) = result.next_schedule {
self.schedule(req.clone())?;
}
Ok(())
}
/// Remove and return all ready scheduled items.
pub fn take_ready_items(&mut self) -> Vec<ScheduledItem> {
self.queue.pop_ready()
}
/// Remove and return only ready items targeted at direct delivery into a
/// specific resumed or spawned session.
pub fn take_ready_direct_items(&mut self) -> Vec<ScheduledItem> {
self.queue.take_ready_direct_items()
}
/// Add a schedule request to the queue. Returns the item ID.
pub fn schedule(&mut self, request: ScheduleRequest) -> Result<String> {
let id = format!("sched_{:08x}", rand::random::<u32>());
let scheduled_for = request.wake_at.unwrap_or_else(|| {
Utc::now() + chrono::Duration::minutes(request.wake_in_minutes.unwrap_or(30) as i64)
});
let item = ScheduledItem {
id: id.clone(),
scheduled_for,
context: request.context,
priority: request.priority,
target: request.target,
created_by_session: request.created_by_session,
created_at: Utc::now(),
working_dir: request.working_dir,
task_description: request.task_description,
relevant_files: request.relevant_files,
git_branch: request.git_branch,
additional_context: request.additional_context,
};
self.queue.push(item);
Ok(id)
}
/// Cancel a queued scheduled item by ID.
pub fn cancel_schedule(&mut self, id: &str) -> Result<Option<ScheduledItem>> {
self.queue.remove_by_id(id)
}
pub fn state(&self) -> &AmbientState {
&self.state
}
pub fn queue(&self) -> &ScheduledQueue {
&self.queue
}
}
@@ -0,0 +1,32 @@
use anyhow::Result;
use std::path::PathBuf;
use crate::storage;
// ---------------------------------------------------------------------------
// Storage paths
// ---------------------------------------------------------------------------
pub(super) fn ambient_dir() -> Result<PathBuf> {
let dir = storage::jcode_dir()?.join("ambient");
storage::ensure_dir(&dir)?;
Ok(dir)
}
pub(super) fn state_path() -> Result<PathBuf> {
Ok(ambient_dir()?.join("state.json"))
}
pub(super) fn queue_path() -> Result<PathBuf> {
Ok(ambient_dir()?.join("queue.json"))
}
pub(super) fn lock_path() -> Result<PathBuf> {
Ok(ambient_dir()?.join("ambient.lock"))
}
pub(super) fn transcripts_dir() -> Result<PathBuf> {
let dir = ambient_dir()?.join("transcripts");
storage::ensure_dir(&dir)?;
Ok(dir)
}
@@ -0,0 +1,217 @@
use anyhow::Result;
use chrono::Utc;
use std::path::PathBuf;
use super::paths::{lock_path, state_path};
use super::{AmbientCycleResult, AmbientState, AmbientStatus, CycleStatus, ScheduledItem};
use crate::storage;
// ---------------------------------------------------------------------------
// AmbientState persistence
// ---------------------------------------------------------------------------
impl AmbientState {
pub fn load() -> Result<Self> {
let path = state_path()?;
if path.exists() {
storage::read_json(&path)
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
storage::write_json(&state_path()?, self)
}
pub fn record_cycle(&mut self, result: &AmbientCycleResult) {
self.last_run = Some(result.ended_at);
self.last_summary = Some(result.summary.clone());
self.last_compactions = Some(result.compactions);
self.last_memories_modified = Some(result.memories_modified);
self.total_cycles += 1;
match result.status {
CycleStatus::Complete => {
if let Some(ref req) = result.next_schedule {
let next = req.wake_at.unwrap_or_else(|| {
Utc::now()
+ chrono::Duration::minutes(req.wake_in_minutes.unwrap_or(30) as i64)
});
self.status = AmbientStatus::Scheduled { next_wake: next };
} else {
self.status = AmbientStatus::Idle;
}
}
CycleStatus::Interrupted | CycleStatus::Incomplete => {
self.status = AmbientStatus::Idle;
}
}
}
}
// ---------------------------------------------------------------------------
// ScheduledQueue
// ---------------------------------------------------------------------------
pub struct ScheduledQueue {
items: Vec<ScheduledItem>,
path: PathBuf,
}
impl ScheduledQueue {
pub fn load(path: PathBuf) -> Self {
let items: Vec<ScheduledItem> = if path.exists() {
storage::read_json(&path).unwrap_or_default()
} else {
Vec::new()
};
Self { items, path }
}
pub fn save(&self) -> Result<()> {
storage::write_json(&self.path, &self.items)
}
pub fn push(&mut self, item: ScheduledItem) {
self.items.push(item);
let _ = self.save();
}
/// Remove a scheduled item by ID, persisting the queue when found.
pub fn remove_by_id(&mut self, id: &str) -> Result<Option<ScheduledItem>> {
let Some(index) = self.items.iter().position(|item| item.id == id) else {
return Ok(None);
};
let item = self.items.remove(index);
self.save()?;
Ok(Some(item))
}
/// Pop items whose `scheduled_for` is in the past, sorted by priority
/// (highest first) then by time (earliest first).
pub fn pop_ready(&mut self) -> Vec<ScheduledItem> {
let now = Utc::now();
let (ready, remaining): (Vec<_>, Vec<_>) =
self.items.drain(..).partition(|i| i.scheduled_for <= now);
self.items = remaining;
let mut ready = ready;
// Sort: highest priority first, then earliest scheduled_for
ready.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.scheduled_for.cmp(&b.scheduled_for))
});
if !ready.is_empty() {
let _ = self.save();
}
ready
}
/// Remove and return ready items targeted at a specific direct-delivery session,
/// leaving ambient-targeted queue items intact for the ambient agent to process.
pub fn take_ready_direct_items(&mut self) -> Vec<ScheduledItem> {
let now = Utc::now();
let mut ready_direct = Vec::new();
let mut remaining = Vec::with_capacity(self.items.len());
for item in self.items.drain(..) {
let is_ready = item.scheduled_for <= now;
let is_direct_target = item.target.is_direct_delivery();
if is_ready && is_direct_target {
ready_direct.push(item);
} else {
remaining.push(item);
}
}
self.items = remaining;
if !ready_direct.is_empty() {
let _ = self.save();
}
ready_direct.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.scheduled_for.cmp(&b.scheduled_for))
});
ready_direct
}
pub fn peek_next(&self) -> Option<&ScheduledItem> {
self.items.iter().min_by_key(|i| i.scheduled_for)
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn items(&self) -> &[ScheduledItem] {
&self.items
}
}
// ---------------------------------------------------------------------------
// AmbientLock (single-instance guard)
// ---------------------------------------------------------------------------
pub struct AmbientLock {
pub(crate) lock_path: PathBuf,
}
impl AmbientLock {
/// Try to acquire the ambient lock.
/// Returns `Ok(Some(lock))` if acquired, `Ok(None)` if another instance
/// already holds it, or `Err` on I/O failure.
pub fn try_acquire() -> Result<Option<Self>> {
let path = lock_path()?;
// Check existing lock
if path.exists() {
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(pid) = contents.trim().parse::<u32>()
&& is_pid_alive(pid)
{
return Ok(None); // Another instance is running
}
let _ = std::fs::remove_file(&path);
}
// Write our PID
let pid = std::process::id();
if let Some(parent) = path.parent() {
storage::ensure_dir(parent)?;
}
std::fs::write(&path, pid.to_string())?;
Ok(Some(Self { lock_path: path }))
}
pub fn release(self) -> Result<()> {
let _ = std::fs::remove_file(&self.lock_path);
// Drop runs, but we already cleaned up
std::mem::forget(self);
Ok(())
}
}
impl Drop for AmbientLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.lock_path);
}
}
fn is_pid_alive(pid: u32) -> bool {
crate::platform::is_process_running(pid)
}
+597
View File
@@ -0,0 +1,597 @@
use chrono::{DateTime, Utc};
use super::{AmbientState, Priority, ScheduleTarget, ScheduledItem, take_pending_directives};
// ---------------------------------------------------------------------------
// Ambient System Prompt Builder
// ---------------------------------------------------------------------------
/// Health stats for the memory graph, used in the ambient system prompt.
#[derive(Debug, Clone, Default)]
pub struct MemoryGraphHealth {
pub total: usize,
pub active: usize,
pub inactive: usize,
pub low_confidence: usize,
pub contradictions: usize,
pub missing_embeddings: usize,
pub duplicate_candidates: usize,
pub last_consolidation: Option<DateTime<Utc>>,
}
/// Summary of a recent session for the ambient prompt.
#[derive(Debug, Clone)]
pub struct RecentSessionInfo {
pub id: String,
pub status: String,
pub topic: Option<String>,
pub duration_secs: i64,
pub extraction_status: String,
}
/// Resource budget info for the ambient prompt.
#[derive(Debug, Clone, Default)]
pub struct ResourceBudget {
pub provider: String,
pub tokens_remaining_desc: String,
pub window_resets_desc: String,
pub user_usage_rate_desc: String,
pub cycle_budget_desc: String,
}
/// Gather memory graph health stats from the MemoryManager.
pub fn gather_memory_graph_health(
memory_manager: &crate::memory::MemoryManager,
) -> MemoryGraphHealth {
let mut health = MemoryGraphHealth::default();
// Accumulate stats from project + global graphs
for graph in [
memory_manager.load_project_graph(),
memory_manager.load_global_graph(),
]
.into_iter()
.flatten()
{
let active_count = graph.memories.values().filter(|m| m.active).count();
let inactive_count = graph.memories.values().filter(|m| !m.active).count();
health.total += graph.memories.len();
health.active += active_count;
health.inactive += inactive_count;
// Low confidence: effective confidence < 0.1
health.low_confidence += graph
.memories
.values()
.filter(|m| m.active && m.effective_confidence() < 0.1)
.count();
// Missing embeddings
health.missing_embeddings += graph
.memories
.values()
.filter(|m| m.active && m.embedding.is_none())
.count();
// Count contradiction edges
for edges in graph.edges.values() {
for edge in edges {
if matches!(edge.kind, crate::memory_graph::EdgeKind::Contradicts) {
health.contradictions += 1;
}
}
}
// Use last_cluster_update as a proxy for last consolidation
if let Some(ts) = graph.metadata.last_cluster_update {
match health.last_consolidation {
Some(existing) if ts > existing => health.last_consolidation = Some(ts),
None => health.last_consolidation = Some(ts),
_ => {}
}
}
}
// Contradicts edges are bidirectional, so divide by 2
health.contradictions /= 2;
// Duplicate candidates would require embedding similarity scan;
// placeholder for now — ambient agent will discover them during its cycle.
health.duplicate_candidates = 0;
health
}
/// Gather feedback memories relevant to ambient mode.
///
/// Pulls from two sources:
/// 1. Recent ambient transcripts (summaries of past cycles)
/// 2. Memory graph entries tagged "ambient" or "system"
///
/// Returns formatted strings for inclusion in the ambient system prompt.
pub fn gather_feedback_memories(memory_manager: &crate::memory::MemoryManager) -> Vec<String> {
let mut feedback = Vec::new();
// --- Source 1: Recent ambient transcripts ---
let transcripts_dir = match crate::storage::jcode_dir() {
Ok(d) => d.join("ambient").join("transcripts"),
Err(_) => return feedback,
};
if transcripts_dir.exists()
&& let Ok(dir) = std::fs::read_dir(&transcripts_dir)
{
let mut files: Vec<_> = dir.flatten().collect();
// Sort by filename descending (most recent first)
files.sort_by_key(|entry| std::cmp::Reverse(entry.file_name()));
// Only look at the last 5 transcripts
files.truncate(5);
for entry in files {
if let Ok(content) = std::fs::read_to_string(entry.path())
&& let Ok(transcript) =
serde_json::from_str::<crate::safety::AmbientTranscript>(&content)
{
let status = format!("{:?}", transcript.status);
let summary = transcript.summary.as_deref().unwrap_or("no summary");
let age = format_duration_rough(Utc::now() - transcript.started_at);
feedback.push(format!(
"Past cycle ({} ago, {}): {} memories modified, {} compactions — {}",
age,
status.to_lowercase(),
transcript.memories_modified,
transcript.compactions,
summary,
));
}
}
}
// --- Source 2: Memory graph entries tagged "ambient" or "system" ---
for graph in [
memory_manager.load_project_graph(),
memory_manager.load_global_graph(),
]
.into_iter()
.flatten()
{
for memory in graph.memories.values() {
if !memory.active {
continue;
}
let has_ambient_tag = memory.tags.iter().any(|t| t == "ambient" || t == "system");
if has_ambient_tag {
feedback.push(format!("Memory [{}]: {}", memory.id, memory.content));
}
}
}
feedback
}
/// Gather recent sessions since a given timestamp.
pub fn gather_recent_sessions(since: Option<DateTime<Utc>>) -> Vec<RecentSessionInfo> {
let sessions_dir = match crate::storage::jcode_dir() {
Ok(d) => d.join("sessions"),
Err(_) => return Vec::new(),
};
if !sessions_dir.exists() {
return Vec::new();
}
let cutoff = since.unwrap_or_else(|| Utc::now() - chrono::Duration::hours(24));
// Pre-filter candidate session files by filesystem mtime BEFORE loading and
// parsing them. The sessions directory can hold tens of thousands of files;
// fully parsing every one via Session::load just to drop those older than
// the cutoff is O(all_sessions * parse). A session updated after the cutoff
// has a recent mtime, so we keep only files whose mtime is at or after the
// cutoff (minus a small margin for clock/write skew), then load newest-first
// and stop once we have enough recent sessions.
const RECENT_SESSION_LIMIT: usize = 20;
let mtime_cutoff = cutoff - chrono::Duration::hours(1);
let mut candidates: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.extension().map(|e| e == "json").unwrap_or(false) {
continue;
}
let Ok(modified) = entry.metadata().and_then(|meta| meta.modified()) else {
// If we can't read mtime, keep the file as a candidate so we
// don't silently drop a possibly-recent session.
candidates.push((path, std::time::SystemTime::UNIX_EPOCH));
continue;
};
let modified_dt: DateTime<Utc> = modified.into();
if modified_dt < mtime_cutoff {
continue;
}
candidates.push((path, modified));
}
}
// Newest files first so we can stop early once we have enough.
candidates.sort_by(|a, b| b.1.cmp(&a.1));
let mut recent = Vec::new();
// Load somewhat more than the final limit by mtime so the subsequent
// id-based sort/truncate picks the true most-recent set even when file
// mtime order and id (timestamp) order disagree near the boundary, while
// still bounding work far below "load every session file".
let load_budget = RECENT_SESSION_LIMIT
.saturating_mul(4)
.max(RECENT_SESSION_LIMIT);
let mut loaded = 0usize;
for (path, _modified) in candidates {
if loaded >= load_budget {
break;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& let Ok(session) = crate::session::Session::load(stem)
{
loaded += 1;
// Skip debug sessions
if session.is_debug {
continue;
}
// Only include sessions updated after cutoff
if session.updated_at < cutoff {
continue;
}
let duration = (session.updated_at - session.created_at)
.num_seconds()
.max(0);
let extraction = if session.messages.is_empty() {
"no messages"
} else {
// Heuristic: if session closed normally, assume extracted
match &session.status {
crate::session::SessionStatus::Closed => "extracted",
crate::session::SessionStatus::Crashed { .. } => "missed",
crate::session::SessionStatus::Active => "in progress",
_ => "unknown",
}
};
recent.push(RecentSessionInfo {
id: session.id.clone(),
status: session.status.display().to_string(),
topic: session.display_title().map(ToOwned::to_owned),
duration_secs: duration,
extraction_status: extraction.to_string(),
});
}
}
// Sort by most recent first (id embeds a timestamp).
recent.sort_by(|a, b| b.id.cmp(&a.id));
recent.truncate(RECENT_SESSION_LIMIT);
recent
}
/// Build the dynamic system prompt for an ambient cycle.
///
/// Populates the template from AMBIENT_MODE.md with real data from the
/// current state, queue, memory graph, sessions, and resource budget.
pub fn build_ambient_system_prompt(
state: &AmbientState,
queue: &[ScheduledItem],
graph_health: &MemoryGraphHealth,
recent_sessions: &[RecentSessionInfo],
feedback_memories: &[String],
budget: &ResourceBudget,
active_user_sessions: usize,
) -> String {
let mut prompt = String::with_capacity(4096);
prompt.push_str(
"You are the ambient agent for jcode. You operate autonomously without \
user prompting. Your job is to maintain and improve the user's \
development environment.\n\n",
);
// --- Current State ---
prompt.push_str("## Current State\n");
if let Some(last_run) = state.last_run {
let ago = Utc::now() - last_run;
let ago_str = format_duration_rough(ago);
prompt.push_str(&format!(
"- Last ambient cycle: {} ({} ago)\n",
last_run.format("%Y-%m-%d %H:%M UTC"),
ago_str,
));
} else {
prompt.push_str("- Last ambient cycle: never (first run)\n");
}
if active_user_sessions > 0 {
prompt.push_str(&format!(
"- Active user sessions: {}\n",
active_user_sessions
));
} else {
prompt.push_str("- Active user sessions: none\n");
}
prompt.push_str(&format!(
"- Total cycles completed: {}\n",
state.total_cycles
));
prompt.push('\n');
// --- Scheduled Queue ---
prompt.push_str("## Scheduled Queue\n");
if queue.is_empty() {
prompt.push_str("Empty -- do general ambient work.\n");
} else {
for item in queue {
let age = Utc::now() - item.created_at;
let priority = match item.priority {
Priority::Low => "low",
Priority::Normal => "normal",
Priority::High => "HIGH",
};
prompt.push_str(&format!(
"- [{}] {} (scheduled {} ago, priority: {})\n",
item.id,
item.context,
format_duration_rough(age),
priority,
));
match &item.target {
ScheduleTarget::Ambient => {}
ScheduleTarget::Session { session_id } => {
prompt.push_str(&format!(" Target session: {}\n", session_id));
}
ScheduleTarget::Spawn { parent_session_id } => {
prompt.push_str(&format!(" Spawn from session: {}\n", parent_session_id));
}
}
if let Some(ref dir) = item.working_dir {
prompt.push_str(&format!(" Working dir: {}\n", dir));
}
if let Some(ref desc) = item.task_description {
prompt.push_str(&format!(" Details: {}\n", desc));
}
if !item.relevant_files.is_empty() {
prompt.push_str(&format!(" Files: {}\n", item.relevant_files.join(", ")));
}
if let Some(ref branch) = item.git_branch {
prompt.push_str(&format!(" Branch: {}\n", branch));
}
if let Some(ref ctx) = item.additional_context {
for line in ctx.lines() {
prompt.push_str(&format!(" {}\n", line));
}
}
}
}
prompt.push('\n');
// --- Recent Sessions ---
prompt.push_str("## Recent Sessions (since last cycle)\n");
if recent_sessions.is_empty() {
prompt.push_str("No sessions since last cycle.\n");
} else {
for s in recent_sessions {
let topic = s.topic.as_deref().unwrap_or("(no title)");
let dur = format_duration_rough(chrono::Duration::seconds(s.duration_secs));
prompt.push_str(&format!(
"- {} | {} | {} | {} | extraction: {}\n",
s.id, s.status, dur, topic, s.extraction_status,
));
}
}
prompt.push('\n');
// --- Memory Graph Health ---
prompt.push_str("## Memory Graph Health\n");
prompt.push_str(&format!(
"- Total memories: {} ({} active, {} inactive)\n",
graph_health.total, graph_health.active, graph_health.inactive,
));
prompt.push_str(&format!(
"- Memories with confidence < 0.1: {}\n",
graph_health.low_confidence,
));
prompt.push_str(&format!(
"- Unresolved contradictions: {}\n",
graph_health.contradictions,
));
prompt.push_str(&format!(
"- Memories without embeddings: {}\n",
graph_health.missing_embeddings,
));
if graph_health.duplicate_candidates > 0 {
prompt.push_str(&format!(
"- Duplicate candidates (similarity > 0.95): {}\n",
graph_health.duplicate_candidates,
));
} else {
prompt.push_str("- Duplicate candidates: run embedding scan to detect\n");
}
if let Some(ts) = graph_health.last_consolidation {
let ago = format_duration_rough(Utc::now() - ts);
prompt.push_str(&format!("- Last consolidation: {} ago\n", ago));
} else {
prompt.push_str("- Last consolidation: never\n");
}
prompt.push('\n');
// --- User Feedback History ---
prompt.push_str("## User Feedback History\n");
if feedback_memories.is_empty() {
prompt.push_str("No feedback memories found about ambient mode yet.\n");
} else {
for mem in feedback_memories {
prompt.push_str(&format!("- {}\n", mem));
}
}
prompt.push('\n');
// --- Resource Budget ---
prompt.push_str("## Resource Budget\n");
prompt.push_str(&format!("- Provider: {}\n", budget.provider));
prompt.push_str(&format!(
"- Tokens remaining in window: {}\n",
budget.tokens_remaining_desc,
));
prompt.push_str(&format!("- Window resets: {}\n", budget.window_resets_desc));
prompt.push_str(&format!(
"- User usage rate: {}\n",
budget.user_usage_rate_desc,
));
prompt.push_str(&format!(
"- Budget for this cycle: {}\n",
budget.cycle_budget_desc,
));
prompt.push('\n');
// --- User Directives (from email/Telegram replies) ---
let pending_directives = take_pending_directives();
if !pending_directives.is_empty() {
prompt.push_str("## User Directives (from replies)\n");
prompt.push_str(
"The user replied to ambient notifications with these instructions. \
Address them as your **top priority** this cycle.\n\n",
);
for dir in &pending_directives {
let ago = format_duration_rough(Utc::now() - dir.received_at);
prompt.push_str(&format!(
"- [reply to cycle {}] ({} ago): {}\n",
dir.in_reply_to_cycle, ago, dir.text,
));
}
prompt.push('\n');
}
// --- Instructions ---
prompt.push_str(
"## Instructions\n\n\
Use the tools that are already available to you in this session. Do \
not search for tools — there is no tool-search/discovery tool, and \
the tools you need are listed below and in your tool definitions.\n\n\
Key tools for this cycle (use these exact names):\n\
- `todo` — plan and track what you'll do this cycle.\n\
- `end_ambient_cycle` — REQUIRED to finish the cycle (see below).\n\
- `schedule_ambient` — schedule your next wake time.\n\
- `request_permission` — get approval before any code change.\n\
- `send_message` — keep the user informed.\n\
Standard tools (`bash`, `read`, `write`, `edit`, `memory`, etc.) are \
also available.\n\n\
Start by using the `todo` tool to plan what you'll do this cycle.\n\n\
Priority order:\n\
1. Execute any scheduled queue items first.\n\
2. Garden the memory graph -- consolidate duplicates, resolve \
contradictions, prune dead memories, verify stale facts, \
extract from missed sessions.\n\
3. Scout for proactive work (only if enabled and past cold start) -- \
look at recent sessions and git history to identify useful work \
the user would appreciate.\n\n\
For gardening: focus on highest-value maintenance first. Duplicates \
and contradictions before pruning. Verify stale facts only if you \
have budget left.\n\n\
For proactive work: be conservative. A bad surprise is worse than \
no surprise. Check the user feedback memories -- if they've rejected \
similar work before, don't do it. Code changes must go on a worktree \
branch with a PR via request_permission.\n\n\
Every request_permission call must be reviewer-ready. Include:\n\
- description: concise summary of what you are about to do\n\
- rationale: why approval is needed right now\n\
- context.summary: what you are working on in this cycle\n\
- context.why_permission_needed: explicit justification for permission\n\
- context.planned_steps, context.files, context.commands (if known)\n\
- context.risks and context.rollback_plan (if relevant)\n\n\
Good sources for scouting proactive work:\n\
- Todoist (via MCP) — check for relevant tasks and deadlines\n\
- Canvas (via MCP) — check for upcoming assignments or deadlines\n\
- Git history — recent commits, open branches, stale PRs\n\
- Session history — patterns in what the user works on\n\n\
When done, you MUST call end_ambient_cycle with a summary of \
everything you did, including compaction count. Always schedule \
your next wake time with context for what you plan to do next.\n\n\
## Messaging Check-ins\n\n\
You have a `send_message` tool. Use it to keep the user informed \
about what you're doing. Send a brief message when you start a cycle \
and when you finish significant work. Keep messages short and useful — \
the user should be able to glance at their messages and know what's happening \
without opening jcode. You can optionally target a specific channel \
(e.g. telegram, discord) or omit channel to send to all.\n",
);
prompt
}
pub fn format_scheduled_session_message(item: &ScheduledItem) -> String {
let mut lines = vec![
"[Scheduled task]".to_string(),
"A scheduled task for this session is now due.".to_string(),
String::new(),
format!(
"Task: {}",
item.task_description.as_deref().unwrap_or(&item.context)
),
];
if let Some(ref dir) = item.working_dir {
lines.push(format!("Working directory: {}", dir));
}
if !item.relevant_files.is_empty() {
lines.push(format!(
"Relevant files: {}",
item.relevant_files.join(", ")
));
}
if let Some(ref branch) = item.git_branch {
lines.push(format!("Branch: {}", branch));
}
if let Some(ref ctx) = item.additional_context {
lines.push(String::new());
lines.push(ctx.clone());
}
lines.join("\n")
}
/// Format a chrono::Duration into a rough human-readable string.
pub(crate) fn format_duration_rough(d: chrono::Duration) -> String {
let secs = d.num_seconds().max(0);
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
let h = secs / 3600;
let m = (secs % 3600) / 60;
if m > 0 {
format!("{}h {}m", h, m)
} else {
format!("{}h", h)
}
} else {
let days = secs / 86400;
format!("{}d", days)
}
}
/// Format a number of minutes into a human-friendly string.
/// E.g. 5 → "5m", 90 → "1h 30m", 370 → "6h 10m", 1500 → "1d 1h"
pub fn format_minutes_human(mins: u32) -> String {
if mins < 60 {
format!("{}m", mins)
} else if mins < 1440 {
let h = mins / 60;
let m = mins % 60;
if m > 0 {
format!("{}h {}m", h, m)
} else {
format!("{}h", h)
}
} else {
let d = mins / 1440;
let h = (mins % 1440) / 60;
if h > 0 {
format!("{}d {}h", d, h)
} else {
format!("{}d", d)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
use super::AmbientRunnerHandle;
use crate::ambient::{Priority, ScheduleTarget, ScheduledItem};
use crate::message::{Message, Role, StreamEvent, ToolDefinition};
use crate::provider::{EventStream, Provider};
use crate::session::Session;
use anyhow::Result;
use async_stream::stream;
use async_trait::async_trait;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
struct EnvVarGuard {
key: &'static str,
prev: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set_path(key: &'static str, value: &std::path::Path) -> Self {
let prev = std::env::var_os(key);
crate::env::set_var(key, value);
Self { key, prev }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(prev) = self.prev.take() {
crate::env::set_var(self.key, prev);
} else {
crate::env::remove_var(self.key);
}
}
}
struct TestProvider;
#[derive(Clone, Default)]
struct StreamingTestProvider {
responses: Arc<StdMutex<VecDeque<Vec<StreamEvent>>>>,
}
impl StreamingTestProvider {
fn queue_response(&self, events: Vec<StreamEvent>) {
self.responses.lock().unwrap().push_back(events);
}
}
#[async_trait]
impl Provider for TestProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
Err(anyhow::anyhow!(
"TestProvider should not be used for streaming completions in ambient runner tests"
))
}
fn name(&self) -> &str {
"test"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(TestProvider)
}
}
#[async_trait]
impl Provider for StreamingTestProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
let events = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_default();
let stream = stream! {
for event in events {
yield Ok(event);
}
};
Ok(Box::pin(stream))
}
fn name(&self) -> &str {
"test"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(self.clone())
}
}
#[tokio::test]
async fn runner_stays_alive_to_service_schedules_when_ambient_disabled() {
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<dyn Provider> = Arc::new(TestProvider);
let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new()));
let task = tokio::spawn(runner.clone().run_loop(provider));
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
runner.is_running().await,
"runner should remain active for scheduled tasks even with ambient disabled"
);
task.abort();
let _ = task.await;
}
#[tokio::test]
async fn spawn_target_creates_one_child_session_and_runs_task() {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::tempdir().expect("tempdir");
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
let provider = StreamingTestProvider::default();
provider.queue_response(vec![
StreamEvent::TextDelta("Spawned session handled task.".to_string()),
StreamEvent::MessageEnd { stop_reason: None },
]);
let provider: Arc<dyn Provider> = Arc::new(provider);
let mut parent = Session::create_with_id(
"session_parent_spawn_test".to_string(),
None,
Some("Parent".to_string()),
);
parent.working_dir = Some(temp.path().display().to_string());
parent.save().expect("save parent session");
let item = ScheduledItem {
id: "sched_spawn_test".to_string(),
scheduled_for: chrono::Utc::now(),
context: "Follow up later".to_string(),
priority: Priority::Normal,
target: ScheduleTarget::Spawn {
parent_session_id: parent.id.clone(),
},
created_by_session: parent.id.clone(),
created_at: chrono::Utc::now(),
working_dir: parent.working_dir.clone(),
task_description: Some("Follow up later".to_string()),
relevant_files: vec!["src/lib.rs".to_string()],
git_branch: None,
additional_context: Some("Background: spawned schedule test".to_string()),
};
let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new()));
let child_session_id = runner
.spawn_session_for_scheduled_item(&provider, &item, &parent.id)
.await
.expect("spawned scheduled task should succeed");
assert_ne!(child_session_id, parent.id);
let child = Session::load(&child_session_id).expect("load spawned child session");
assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str()));
assert_eq!(child.working_dir, parent.working_dir);
assert!(child.messages.iter().any(|message| {
message.role == Role::User
&& message.content_preview().contains("[Scheduled task]")
&& message.content_preview().contains("Follow up later")
}));
assert!(child.messages.iter().any(|message| {
message.role == Role::Assistant
&& message
.content_preview()
.contains("Spawned session handled task.")
}));
}
@@ -0,0 +1,497 @@
//! Adaptive usage calculator for ambient mode scheduling.
//!
//! Tracks per-call token usage (user vs ambient), maintains a rolling usage log,
//! and computes adaptive intervals for ambient cycles based on rate limit headroom.
use crate::storage;
use chrono::{Duration as ChronoDuration, Utc};
use std::path::PathBuf;
use std::time::Duration;
// ---------------------------------------------------------------------------
// Usage record types
// ---------------------------------------------------------------------------
pub use jcode_ambient_types::{RateLimitInfo, UsageRecord, UsageSource};
// ---------------------------------------------------------------------------
// Usage log — rolling, persisted to disk
// ---------------------------------------------------------------------------
/// How often to auto-save (every N records added).
const SAVE_INTERVAL: usize = 10;
/// Records older than this are pruned on save.
const PRUNE_AGE_HOURS: i64 = 24;
pub struct UsageLog {
records: Vec<UsageRecord>,
path: PathBuf,
unsaved_count: usize,
}
impl UsageLog {
/// Load (or create) the usage log from the default path.
pub fn load() -> Self {
let path = Self::default_path();
let records: Vec<UsageRecord> = if path.exists() {
storage::read_json(&path).unwrap_or_default()
} else {
Vec::new()
};
UsageLog {
records,
path,
unsaved_count: 0,
}
}
fn default_path() -> PathBuf {
storage::jcode_dir()
.unwrap_or_else(|_| std::env::temp_dir())
.join("ambient")
.join("usage.json")
}
/// Add a record and periodically save.
pub fn record(&mut self, record: UsageRecord) {
self.records.push(record);
self.unsaved_count += 1;
if self.unsaved_count >= SAVE_INTERVAL
&& let Err(err) = self.save()
{
crate::logging::warn(&format!(
"Failed to persist ambient usage log '{}': {}",
self.path.display(),
err
));
}
}
/// Rolling average of *user* token usage per minute over `window`.
pub fn user_rate_per_minute(&self, window: Duration) -> f32 {
self.rate_per_minute(UsageSource::User, window)
}
/// Rolling average of *ambient* token usage per minute over `window`.
pub fn ambient_rate_per_minute(&self, window: Duration) -> f32 {
self.rate_per_minute(UsageSource::Ambient, window)
}
/// Total tokens for a given source within a window.
pub fn total_tokens_in_window(&self, source: &UsageSource, window: Duration) -> u64 {
let cutoff = Utc::now() - ChronoDuration::from_std(window).unwrap_or_default();
self.records
.iter()
.filter(|r| r.source == *source && r.timestamp >= cutoff)
.map(|r| r.total_tokens())
.sum()
}
/// Average tokens per ambient cycle (last N cycles).
pub fn avg_tokens_per_ambient_cycle(&self, last_n: usize) -> Option<f64> {
let ambient: Vec<u64> = self
.records
.iter()
.rev()
.filter(|r| r.source == UsageSource::Ambient)
.take(last_n)
.map(|r| r.total_tokens())
.collect();
if ambient.is_empty() {
return None;
}
let sum: u64 = ambient.iter().sum();
Some(sum as f64 / ambient.len() as f64)
}
/// Persist to disk, pruning old records.
pub fn save(&mut self) -> anyhow::Result<()> {
self.prune();
storage::write_json(&self.path, &self.records)?;
self.unsaved_count = 0;
Ok(())
}
// -- internal helpers ---------------------------------------------------
fn rate_per_minute(&self, source: UsageSource, window: Duration) -> f32 {
let cutoff = Utc::now() - ChronoDuration::from_std(window).unwrap_or_default();
let total: u64 = self
.records
.iter()
.filter(|r| r.source == source && r.timestamp >= cutoff)
.map(|r| r.total_tokens())
.sum();
let minutes = window.as_secs_f32() / 60.0;
if minutes > 0.0 {
total as f32 / minutes
} else {
0.0
}
}
fn prune(&mut self) {
let cutoff = Utc::now() - ChronoDuration::hours(PRUNE_AGE_HOURS);
self.records.retain(|r| r.timestamp >= cutoff);
}
}
// ---------------------------------------------------------------------------
// Scheduler config
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct AmbientSchedulerConfig {
pub min_interval_minutes: u32,
pub max_interval_minutes: u32,
pub pause_on_active_session: bool,
/// Fraction of remaining budget reserved for user. 0.8 means ambient gets
/// at most 20% of headroom.
pub user_budget_reserve: f32,
}
impl Default for AmbientSchedulerConfig {
fn default() -> Self {
AmbientSchedulerConfig {
min_interval_minutes: 5,
max_interval_minutes: 120,
pause_on_active_session: true,
user_budget_reserve: 0.8,
}
}
}
// ---------------------------------------------------------------------------
// Adaptive scheduler
// ---------------------------------------------------------------------------
pub struct AdaptiveScheduler {
pub usage_log: UsageLog,
pub config: AmbientSchedulerConfig,
/// Exponential backoff multiplier (doubles on rate limit hits).
backoff_multiplier: u32,
/// Whether a user session is currently active.
user_active: bool,
}
impl AdaptiveScheduler {
pub fn new(config: AmbientSchedulerConfig) -> Self {
AdaptiveScheduler {
usage_log: UsageLog::load(),
config,
backoff_multiplier: 1,
user_active: false,
}
}
/// Core interval calculation following the algorithm in AMBIENT_MODE.md.
pub fn calculate_interval(&self, rate_limit_info: Option<&RateLimitInfo>) -> Duration {
let max = Duration::from_secs(self.config.max_interval_minutes as u64 * 60);
let min = Duration::from_secs(self.config.min_interval_minutes as u64 * 60);
// If no rate limit info, fall back to max interval.
let info = match rate_limit_info {
Some(i) => i,
None => return self.apply_backoff(max),
};
// window_remaining = reset_time - now
let window_remaining_secs = info
.reset_at
.map(|r| {
let diff = r - Utc::now();
diff.num_seconds().max(0) as f64
})
.unwrap_or(3600.0); // default 1 hour if unknown
let tokens_remaining = info.remaining_tokens.unwrap_or(0) as f64;
if tokens_remaining <= 0.0 || window_remaining_secs <= 0.0 {
return self.apply_backoff(max);
}
// Estimate user consumption from rolling history (last hour).
let user_rate = self
.usage_log
.user_rate_per_minute(Duration::from_secs(3600)) as f64;
// Project user usage for rest of window.
let window_remaining_minutes = window_remaining_secs / 60.0;
let user_projected = user_rate * window_remaining_minutes;
// Ambient budget = (remaining - user_projected) * (1 - reserve)
let ambient_fraction = 1.0 - self.config.user_budget_reserve as f64;
let ambient_budget = (tokens_remaining - user_projected) * ambient_fraction;
if ambient_budget <= 0.0 {
// No headroom — wait until window resets.
return self.apply_backoff(max);
}
// Estimate cost per ambient cycle from recent cycles.
let tokens_per_cycle = self
.usage_log
.avg_tokens_per_ambient_cycle(5)
.unwrap_or(10_000.0); // conservative default
let cycles_available = ambient_budget / tokens_per_cycle;
let interval_secs = if cycles_available > 0.0 {
window_remaining_secs / cycles_available
} else {
window_remaining_secs
};
let interval = Duration::from_secs_f64(interval_secs);
self.apply_backoff(interval.clamp(min, max))
}
/// Returns `true` if the scheduler thinks ambient should pause (user active).
pub fn should_pause(&self) -> bool {
self.config.pause_on_active_session && self.user_active
}
/// Mark user session state.
pub fn set_user_active(&mut self, active: bool) {
self.user_active = active;
}
/// Called when a provider rate limit error occurs.
pub fn on_rate_limit_hit(&mut self) {
self.backoff_multiplier = self.backoff_multiplier.saturating_mul(2).min(64);
}
/// Called after a successful ambient cycle.
pub fn on_successful_cycle(&mut self) {
self.backoff_multiplier = 1;
}
// -- internal --
fn apply_backoff(&self, interval: Duration) -> Duration {
let min = Duration::from_secs(self.config.min_interval_minutes as u64 * 60);
let max = Duration::from_secs(self.config.max_interval_minutes as u64 * 60);
let adjusted = interval.saturating_mul(self.backoff_multiplier);
adjusted.clamp(min, max)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn make_record(source: UsageSource, tokens: u32, mins_ago: i64) -> UsageRecord {
UsageRecord {
timestamp: Utc::now() - ChronoDuration::minutes(mins_ago),
source,
tokens_input: tokens / 2,
tokens_output: tokens / 2,
provider: "test".to_string(),
}
}
#[test]
fn test_usage_log_rate_per_minute() {
let mut log = UsageLog {
records: Vec::new(),
path: PathBuf::from("/tmp/test_usage.json"),
unsaved_count: 0,
};
// Add 3 user records in the last 30 minutes, 1000 tokens each.
for i in 0..3 {
log.records
.push(make_record(UsageSource::User, 1000, i * 10));
}
let rate = log.user_rate_per_minute(Duration::from_secs(3600));
// 3000 tokens over 60 minutes = 50 tokens/min
assert!((rate - 50.0).abs() < 1.0, "got {}", rate);
}
#[test]
fn test_total_tokens_in_window() {
let mut log = UsageLog {
records: Vec::new(),
path: PathBuf::from("/tmp/test_usage2.json"),
unsaved_count: 0,
};
log.records.push(make_record(UsageSource::User, 500, 10));
log.records.push(make_record(UsageSource::Ambient, 300, 5));
log.records.push(make_record(UsageSource::User, 200, 2));
let user_total = log.total_tokens_in_window(&UsageSource::User, Duration::from_secs(3600));
assert_eq!(user_total, 700);
let ambient_total =
log.total_tokens_in_window(&UsageSource::Ambient, Duration::from_secs(3600));
assert_eq!(ambient_total, 300);
}
#[test]
fn test_avg_tokens_per_ambient_cycle() {
let mut log = UsageLog {
records: Vec::new(),
path: PathBuf::from("/tmp/test_usage3.json"),
unsaved_count: 0,
};
// No ambient records => None.
assert!(log.avg_tokens_per_ambient_cycle(5).is_none());
log.records
.push(make_record(UsageSource::Ambient, 1000, 30));
log.records
.push(make_record(UsageSource::Ambient, 2000, 20));
log.records
.push(make_record(UsageSource::Ambient, 3000, 10));
let avg = log.avg_tokens_per_ambient_cycle(5).unwrap();
assert!((avg - 2000.0).abs() < 1.0, "got {}", avg);
}
#[test]
fn test_scheduler_no_rate_limit_returns_max() {
let config = AmbientSchedulerConfig {
min_interval_minutes: 5,
max_interval_minutes: 120,
..Default::default()
};
let scheduler = AdaptiveScheduler::new(config);
let interval = scheduler.calculate_interval(None);
assert_eq!(interval, Duration::from_secs(120 * 60));
}
#[test]
fn test_scheduler_no_remaining_tokens_returns_max() {
let config = AmbientSchedulerConfig::default();
let scheduler = AdaptiveScheduler::new(config);
let info = RateLimitInfo {
limit_tokens: Some(100_000),
remaining_tokens: Some(0),
limit_requests: None,
remaining_requests: None,
reset_at: Some(Utc::now() + ChronoDuration::hours(1)),
};
let interval = scheduler.calculate_interval(Some(&info));
assert_eq!(interval, Duration::from_secs(120 * 60));
}
#[test]
fn test_scheduler_plenty_of_headroom() {
let config = AmbientSchedulerConfig {
min_interval_minutes: 5,
max_interval_minutes: 120,
user_budget_reserve: 0.8,
..Default::default()
};
let scheduler = AdaptiveScheduler::new(config);
let info = RateLimitInfo {
limit_tokens: Some(1_000_000),
remaining_tokens: Some(500_000),
limit_requests: None,
remaining_requests: None,
reset_at: Some(Utc::now() + ChronoDuration::hours(1)),
};
let interval = scheduler.calculate_interval(Some(&info));
// With 500k remaining, 0 user rate, 20% for ambient = 100k budget.
// Default 10k per cycle => 10 cycles in 60 min => 6 min per cycle.
let mins = interval.as_secs() as f64 / 60.0;
assert!(
(5.0..=10.0).contains(&mins),
"expected 5-10 min, got {:.1}",
mins
);
}
#[test]
fn test_backoff_doubles() {
let config = AmbientSchedulerConfig {
min_interval_minutes: 5,
max_interval_minutes: 120,
..Default::default()
};
let mut scheduler = AdaptiveScheduler::new(config);
let info = RateLimitInfo {
limit_tokens: Some(1_000_000),
remaining_tokens: Some(500_000),
limit_requests: None,
remaining_requests: None,
reset_at: Some(Utc::now() + ChronoDuration::hours(1)),
};
let before = scheduler.calculate_interval(Some(&info));
scheduler.on_rate_limit_hit();
let after = scheduler.calculate_interval(Some(&info));
// After one hit, interval should roughly double (clamped).
assert!(
after >= before,
"after backoff should be >= before: {:?} vs {:?}",
after,
before
);
}
#[test]
fn test_backoff_resets_on_success() {
let config = AmbientSchedulerConfig::default();
let mut scheduler = AdaptiveScheduler::new(config);
scheduler.on_rate_limit_hit();
scheduler.on_rate_limit_hit();
assert!(scheduler.backoff_multiplier > 1);
scheduler.on_successful_cycle();
assert_eq!(scheduler.backoff_multiplier, 1);
}
#[test]
fn test_should_pause() {
let config = AmbientSchedulerConfig {
pause_on_active_session: true,
..Default::default()
};
let mut scheduler = AdaptiveScheduler::new(config);
assert!(!scheduler.should_pause());
scheduler.set_user_active(true);
assert!(scheduler.should_pause());
scheduler.set_user_active(false);
assert!(!scheduler.should_pause());
}
#[test]
fn test_prune_removes_old_records() {
let mut log = UsageLog {
records: Vec::new(),
path: PathBuf::from("/tmp/test_prune.json"),
unsaved_count: 0,
};
// Record from 25 hours ago (should be pruned).
log.records.push(UsageRecord {
timestamp: Utc::now() - ChronoDuration::hours(25),
source: UsageSource::User,
tokens_input: 100,
tokens_output: 100,
provider: "test".to_string(),
});
// Recent record (should survive).
log.records.push(make_record(UsageSource::User, 200, 5));
log.prune();
assert_eq!(log.records.len(), 1);
assert_eq!(log.records[0].total_tokens(), 200);
}
}
@@ -0,0 +1,3 @@
#![cfg_attr(test, allow(clippy::await_holding_lock))]
pub use crate::ambient::runner::AmbientRunnerHandle;
@@ -0,0 +1,3 @@
pub use crate::ambient::scheduler::{
AdaptiveScheduler, AmbientSchedulerConfig, RateLimitInfo, UsageLog, UsageRecord, UsageSource,
};
+457
View File
@@ -0,0 +1,457 @@
use super::*;
use chrono::Duration;
#[test]
fn test_ambient_status_default() {
let status = AmbientStatus::default();
assert_eq!(status, AmbientStatus::Idle);
}
#[test]
fn test_priority_ordering() {
assert!(Priority::High > Priority::Normal);
assert!(Priority::Normal > Priority::Low);
}
#[test]
fn test_scheduled_queue_push_and_pop() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path);
assert!(queue.is_empty());
let past = Utc::now() - Duration::minutes(5);
let future = Utc::now() + Duration::hours(1);
queue.push(ScheduledItem {
id: "s1".into(),
scheduled_for: past,
context: "past item".into(),
priority: Priority::Low,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "s2".into(),
scheduled_for: future,
context: "future item".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
assert_eq!(queue.len(), 2);
let ready = queue.pop_ready();
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].id, "s1");
// Future item still in queue
assert_eq!(queue.len(), 1);
assert_eq!(queue.peek_next().unwrap().id, "s2");
}
#[test]
fn test_scheduled_queue_remove_by_id_persists_remaining_items() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path.clone());
let future = Utc::now() + Duration::hours(1);
queue.push(ScheduledItem {
id: "keep".into(),
scheduled_for: future,
context: "keep item".into(),
priority: Priority::Normal,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "cancel".into(),
scheduled_for: future,
context: "cancel item".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
let removed = queue.remove_by_id("cancel").unwrap().unwrap();
assert_eq!(removed.id, "cancel");
assert!(queue.remove_by_id("missing").unwrap().is_none());
let reloaded = ScheduledQueue::load(path);
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded.items()[0].id, "keep");
}
#[test]
fn test_pop_ready_sorts_by_priority_then_time() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path);
let past1 = Utc::now() - Duration::minutes(10);
let past2 = Utc::now() - Duration::minutes(5);
queue.push(ScheduledItem {
id: "low_early".into(),
scheduled_for: past1,
context: "low early".into(),
priority: Priority::Low,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "high_late".into(),
scheduled_for: past2,
context: "high late".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
let ready = queue.pop_ready();
assert_eq!(ready.len(), 2);
// High priority should come first
assert_eq!(ready[0].id, "high_late");
assert_eq!(ready[1].id, "low_early");
}
#[test]
fn test_take_ready_direct_items_only_removes_direct_targets() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path);
let past = Utc::now() - Duration::minutes(5);
queue.push(ScheduledItem {
id: "session_due".into(),
scheduled_for: past,
context: "scheduled session task".into(),
priority: Priority::Normal,
target: ScheduleTarget::Session {
session_id: "session_123".into(),
},
created_by_session: "session_123".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "spawn_due".into(),
scheduled_for: past,
context: "spawned session task".into(),
priority: Priority::High,
target: ScheduleTarget::Spawn {
parent_session_id: "session_123".into(),
},
created_by_session: "session_123".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "ambient_due".into(),
scheduled_for: past,
context: "scheduled ambient task".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "ambient".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
let ready_direct = queue.take_ready_direct_items();
assert_eq!(ready_direct.len(), 2);
assert_eq!(ready_direct[0].id, "spawn_due");
assert_eq!(ready_direct[1].id, "session_due");
assert_eq!(queue.len(), 1);
assert_eq!(queue.items()[0].id, "ambient_due");
}
#[test]
fn test_ambient_state_record_cycle() {
let mut state = AmbientState::default();
assert_eq!(state.total_cycles, 0);
let result = AmbientCycleResult {
summary: "Merged 2 duplicates".into(),
memories_modified: 3,
compactions: 1,
proactive_work: None,
next_schedule: None,
started_at: Utc::now() - Duration::seconds(30),
ended_at: Utc::now(),
status: CycleStatus::Complete,
conversation: None,
};
state.record_cycle(&result);
assert_eq!(state.total_cycles, 1);
assert_eq!(state.last_summary.as_deref(), Some("Merged 2 duplicates"));
assert_eq!(state.last_compactions, Some(1));
assert_eq!(state.last_memories_modified, Some(3));
assert_eq!(state.status, AmbientStatus::Idle);
}
#[test]
fn test_ambient_state_record_cycle_with_schedule() {
let mut state = AmbientState::default();
let result = AmbientCycleResult {
summary: "Done".into(),
memories_modified: 0,
compactions: 0,
proactive_work: None,
next_schedule: Some(ScheduleRequest {
wake_in_minutes: Some(15),
wake_at: None,
context: "check CI".into(),
priority: Priority::Normal,
target: ScheduleTarget::Ambient,
created_by_session: "ambient_test".into(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
}),
started_at: Utc::now() - Duration::seconds(10),
ended_at: Utc::now(),
status: CycleStatus::Complete,
conversation: None,
};
state.record_cycle(&result);
assert!(matches!(state.status, AmbientStatus::Scheduled { .. }));
}
#[test]
fn test_ambient_lock_release() {
// Use a temp dir so we don't conflict with real state
let tmp_dir = tempfile::tempdir().unwrap();
let lock_file = tmp_dir.path().join("test.lock");
// Manually create a lock to test release/drop
std::fs::write(&lock_file, std::process::id().to_string()).unwrap();
let lock = AmbientLock {
lock_path: lock_file.clone(),
};
lock.release().unwrap();
assert!(!lock_file.exists());
}
#[test]
fn test_schedule_id_format() {
let id = format!("sched_{:08x}", rand::random::<u32>());
assert!(id.starts_with("sched_"));
assert_eq!(id.len(), 6 + 8); // "sched_" + 8 hex chars
}
#[test]
fn test_format_duration_rough() {
assert_eq!(format_duration_rough(Duration::seconds(30)), "30s");
assert_eq!(format_duration_rough(Duration::minutes(5)), "5m");
assert_eq!(format_duration_rough(Duration::hours(2)), "2h");
assert_eq!(
format_duration_rough(Duration::hours(2) + Duration::minutes(30)),
"2h 30m"
);
assert_eq!(format_duration_rough(Duration::days(3)), "3d");
assert_eq!(format_duration_rough(Duration::seconds(-5)), "0s");
}
#[test]
fn test_build_ambient_system_prompt_minimal() {
let state = AmbientState::default();
let queue = vec![];
let health = MemoryGraphHealth::default();
let sessions = vec![];
let feedback: Vec<String> = vec![];
let budget = ResourceBudget {
provider: "anthropic-oauth".into(),
tokens_remaining_desc: "unknown".into(),
window_resets_desc: "unknown".into(),
user_usage_rate_desc: "0 tokens/min".into(),
cycle_budget_desc: "stay under 50k tokens".into(),
};
let prompt =
build_ambient_system_prompt(&state, &queue, &health, &sessions, &feedback, &budget, 0);
assert!(prompt.contains("ambient agent for jcode"));
assert!(prompt.contains("## Current State"));
assert!(prompt.contains("never (first run)"));
assert!(prompt.contains("Active user sessions: none"));
assert!(prompt.contains("## Scheduled Queue"));
assert!(prompt.contains("Empty"));
assert!(prompt.contains("## Memory Graph Health"));
assert!(prompt.contains("Total memories: 0"));
assert!(prompt.contains("## User Feedback History"));
assert!(prompt.contains("No feedback memories"));
assert!(prompt.contains("## Resource Budget"));
assert!(prompt.contains("anthropic-oauth"));
assert!(prompt.contains("## Instructions"));
assert!(prompt.contains("end_ambient_cycle"));
assert!(prompt.contains("reviewer-ready"));
assert!(prompt.contains("context.why_permission_needed"));
}
#[test]
fn test_build_ambient_system_prompt_with_data() {
let state = AmbientState {
last_run: Some(Utc::now() - Duration::minutes(15)),
total_cycles: 7,
..Default::default()
};
let queue = vec![ScheduledItem {
id: "sched_001".into(),
scheduled_for: Utc::now(),
context: "Check CI status".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "session_abc".into(),
created_at: Utc::now() - Duration::minutes(10),
working_dir: Some("/home/user/project".into()),
task_description: Some("Check CI status for the main branch".into()),
relevant_files: vec!["src/main.rs".into()],
git_branch: Some("main".into()),
additional_context: Some("Background: Tests were flaky yesterday".into()),
}];
let health = MemoryGraphHealth {
total: 42,
active: 38,
inactive: 4,
low_confidence: 3,
contradictions: 1,
missing_embeddings: 5,
duplicate_candidates: 0,
last_consolidation: Some(Utc::now() - Duration::hours(2)),
};
let sessions = vec![RecentSessionInfo {
id: "session_fox_123".into(),
status: "closed".into(),
topic: Some("Fix auth bug".into()),
duration_secs: 900,
extraction_status: "extracted".into(),
}];
let feedback = vec![
"User approved ambient fixing typos in docs".into(),
"User rejected ambient refactoring tests".into(),
];
let budget = ResourceBudget {
provider: "openai-oauth".into(),
tokens_remaining_desc: "~85k".into(),
window_resets_desc: "in 3h 20m".into(),
user_usage_rate_desc: "120 tokens/min".into(),
cycle_budget_desc: "stay under 15k tokens".into(),
};
let prompt =
build_ambient_system_prompt(&state, &queue, &health, &sessions, &feedback, &budget, 2);
assert!(prompt.contains("15m ago"));
assert!(prompt.contains("Active user sessions: 2"));
assert!(prompt.contains("Total cycles completed: 7"));
assert!(prompt.contains("Check CI status"));
assert!(prompt.contains("HIGH"));
assert!(prompt.contains("42"));
assert!(prompt.contains("38 active"));
assert!(prompt.contains("confidence < 0.1: 3"));
assert!(prompt.contains("contradictions: 1"));
assert!(prompt.contains("without embeddings: 5"));
assert!(prompt.contains("Fix auth bug"));
assert!(prompt.contains("approved ambient fixing typos"));
assert!(prompt.contains("rejected ambient refactoring"));
assert!(prompt.contains("openai-oauth"));
assert!(prompt.contains("~85k"));
assert!(prompt.contains("Working dir: /home/user/project"));
assert!(prompt.contains("Details: Check CI status for the main branch"));
assert!(prompt.contains("Files: src/main.rs"));
assert!(prompt.contains("Branch: main"));
assert!(prompt.contains("Tests were flaky yesterday"));
}
#[test]
fn test_scheduled_queue_items_accessor() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path);
queue.push(ScheduledItem {
id: "s1".into(),
scheduled_for: Utc::now(),
context: "test item".into(),
priority: Priority::Normal,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
let items = queue.items();
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "s1");
}
+27
View File
@@ -0,0 +1,27 @@
pub use jcode_build_support::{
BinaryChoice, BinaryVersionReport, BuildInfo, BuildManifest, CanaryStatus, CrashInfo,
DevBinarySourceMetadata, MigrationContext, PendingActivation, PublishedBuild,
SELFDEV_CARGO_PROFILE, SelfDevBuildCommand, SelfDevBuildTarget, SharedServerRepair,
SourceState, advance_shared_server_if_tracking_stable, binary_name, binary_stem,
build_log_path, build_progress_path, builds_dir, canary_binary_path, clear_build_progress,
clear_migration_context, client_update_candidate, complete_pending_activation_for_session,
current_binary_build_time_string, current_binary_built_at, current_binary_path,
current_build_info, current_git_diff, current_git_hash, current_git_hash_full,
current_source_state, current_version_file, ensure_source_state_matches, find_dev_binary,
find_repo_in_ancestors, get_commit_message, get_repo_dir, install_binary_at_version,
install_local_release, install_version, is_jcode_repo, is_working_tree_dirty,
launcher_binary_path, launcher_dir, load_migration_context, manifest_path,
migration_context_path, preferred_reload_candidate, promote_version_to_shared_server,
publish_local_current_build, publish_local_current_build_for_source, read_build_progress,
read_current_version, read_shared_server_version, read_stable_version, release_binary_path,
repair_stale_shared_server_channel, repo_build_version, repo_scope_key, resolve_binary_payload,
rollback_pending_activation_for_session, run_selfdev_build, save_migration_context,
selfdev_binary_path, selfdev_build_command, selfdev_build_command_for_target,
shared_server_binary_path, shared_server_tracks_stable, shared_server_update_candidate,
shared_server_version_file, smoke_test_binary, smoke_test_server_binary, stable_binary_path,
stable_version_file, update_canary_symlink, update_current_symlink,
update_launcher_symlink_to_current, update_launcher_symlink_to_stable,
update_shared_server_symlink, update_stable_symlink, version_binary_path,
version_matches_installed_channel, worktree_scope_key, write_build_progress,
write_current_dev_binary_source_metadata, write_dev_binary_source_metadata,
};
+651
View File
@@ -0,0 +1,651 @@
use crate::message::ContentBlock;
use crate::session::{Session, SessionStatus};
use anyhow::Result;
use chrono::{DateTime, Utc};
use std::collections::{BTreeMap, HashSet};
pub use jcode_task_types::{CatchupBrief, PersistedCatchupState};
const CATCHUP_STATE_FILE: &str = "catchup_seen.json";
pub fn needs_catchup(session_id: &str, updated_at: DateTime<Utc>, status: &SessionStatus) -> bool {
if !is_attention_status(status) {
return false;
}
let seen = load_seen_state()
.seen_at_ms_by_session
.get(session_id)
.copied();
needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status)
}
/// Snapshot of the persisted catch-up "seen" state, so callers that need to
/// evaluate many sessions at once (e.g. the session picker building its list)
/// can avoid re-reading and re-parsing `catchup_seen.json` once per session.
#[derive(Clone, Default)]
pub struct CatchupSeenSnapshot {
state: PersistedCatchupState,
}
impl CatchupSeenSnapshot {
/// Load the persisted seen-state once from disk.
pub fn load() -> Self {
Self {
state: load_seen_state(),
}
}
/// Same semantics as [`needs_catchup`] but uses this preloaded snapshot
/// instead of re-reading the state file for every call.
pub fn needs_catchup(
&self,
session_id: &str,
updated_at: DateTime<Utc>,
status: &SessionStatus,
) -> bool {
if !is_attention_status(status) {
return false;
}
let seen = self.state.seen_at_ms_by_session.get(session_id).copied();
needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status)
}
}
pub(crate) fn needs_catchup_with_seen(
updated_at_ms: i64,
seen_at_ms: Option<i64>,
status: &SessionStatus,
) -> bool {
is_attention_status(status) && seen_at_ms.unwrap_or_default() < updated_at_ms
}
pub fn mark_seen(session_id: &str, updated_at: DateTime<Utc>) -> Result<()> {
let mut state = load_seen_state();
state
.seen_at_ms_by_session
.insert(session_id.to_string(), updated_at.timestamp_millis());
save_seen_state(&state)
}
pub fn build_brief(session: &Session) -> CatchupBrief {
let rendered = crate::session::render_messages(session);
let last_user_prompt = rendered
.iter()
.rev()
.find(|msg| msg.role == "user" && !msg.content.trim().is_empty())
.map(|msg| msg.content.trim().to_string());
let latest_agent_response = rendered
.iter()
.rev()
.find(|msg| msg.role == "assistant" && !msg.content.trim().is_empty())
.map(|msg| msg.content.trim().to_string());
let files_touched = collect_touched_files(session);
let tool_counts = collect_tool_counts(session);
let validation_notes = collect_validation_notes(&rendered);
let activity_steps = collect_activity_steps(session);
let (reason, tags) = reason_and_tags(&session.status);
let needs_from_user = infer_needs_from_user(&session.status, latest_agent_response.as_deref());
CatchupBrief {
reason,
tags,
last_user_prompt,
activity_steps,
files_touched,
tool_counts,
validation_notes,
latest_agent_response,
needs_from_user,
updated_at: session.updated_at,
}
}
pub fn render_markdown(
session: &Session,
source_session_id: Option<&str>,
queue_position: Option<(usize, usize)>,
brief: &CatchupBrief,
) -> String {
let display_name = session.display_name().to_string();
let icon = crate::id::session_icon(&display_name);
let status_icon = status_icon(&session.status);
let status_label = status_label(&session.status);
let updated_ago = format_time_ago(brief.updated_at);
let source_label = source_session_id
.and_then(crate::id::extract_session_name)
.unwrap_or("previous session");
let mut out = String::new();
out.push_str("# Catch Up\n\n");
out.push_str(&format!(
"**{} {}** · {} **{}** · updated {}\n\n",
icon, display_name, status_icon, status_label, updated_ago
));
if let Some((index, total)) = queue_position {
out.push_str(&format!("- Queue: **{} of {}**\n", index, total));
}
if source_session_id.is_some() {
out.push_str(&format!("- From: **{}**\n", source_label));
}
out.push_str(&format!("- Session: `{}`\n\n", session.id));
if crate::config::config().features.mermaid && !brief.activity_steps.is_empty() {
out.push_str("```mermaid\nflowchart TD\n");
out.push_str(&format!(
" A[\"Why<br/>{}\"]:::status --> B[\"Your last prompt\"]:::user\n",
mermaid_escape(&brief.reason)
));
let mut prev = 'B';
for (idx, step) in brief.activity_steps.iter().take(4).enumerate() {
let node = ((b'C' + idx as u8) as char).to_string();
out.push_str(&format!(
" {}[\"{}\"]:::step\n",
node,
mermaid_escape(step)
));
out.push_str(&format!(" {} --> {}\n", prev, node));
prev = node.chars().next().unwrap_or('B');
}
out.push_str(&format!(
" {} --> Z[\"Need from you<br/>{}\"]:::decision\n",
prev,
mermaid_escape(&brief.needs_from_user)
));
out.push_str(" classDef status fill:#18331f,stroke:#4caf50,color:#d6ffd9;\n");
out.push_str(" classDef user fill:#1f3659,stroke:#7fb3ff,color:#e8f1ff;\n");
out.push_str(" classDef step fill:#2b2b33,stroke:#9090a0,color:#f0f0f5;\n");
out.push_str(" classDef decision fill:#43284f,stroke:#d38cff,color:#fdefff;\n");
out.push_str("```\n\n");
}
out.push_str("## Why this needs attention\n\n");
out.push_str(&format!("> {}\n\n", brief.reason));
if !brief.tags.is_empty() {
out.push_str(&format!(
"{}\n\n",
brief
.tags
.iter()
.map(|tag| format!("`{}`", tag))
.collect::<Vec<_>>()
.join(" ")
));
}
out.push_str("## Your last prompt\n\n");
if let Some(prompt) = brief.last_user_prompt.as_deref() {
out.push_str(&format!("> {}\n\n", markdown_quote(prompt)));
} else {
out.push_str("> No user prompt found in the restored transcript.\n\n");
}
out.push_str("## What happened\n\n");
if brief.activity_steps.is_empty() {
out.push_str("- No tool activity was reconstructed from the stored transcript.\n\n");
} else {
for step in &brief.activity_steps {
out.push_str(&format!("- {}\n", step));
}
out.push('\n');
}
out.push_str("## What changed\n\n");
if brief.files_touched.is_empty() {
out.push_str("- Files: _no explicit file paths captured_\n");
} else {
out.push_str(&format!(
"- Files: {}\n",
brief
.files_touched
.iter()
.take(5)
.map(|path| format!("`{}`", path))
.collect::<Vec<_>>()
.join(", ")
));
}
if brief.tool_counts.is_empty() {
out.push_str("- Tools: _none captured_\n");
} else {
out.push_str(&format!(
"- Tools: {}\n",
brief
.tool_counts
.iter()
.take(6)
.map(|(name, count)| format!("`{}`×{}", name, count))
.collect::<Vec<_>>()
.join(" · ")
));
}
if brief.validation_notes.is_empty() {
out.push_str("- Validation: _no test/build validation detected_\n\n");
} else {
out.push_str("- Validation:\n");
for note in &brief.validation_notes {
out.push_str(&format!(" - {}\n", note));
}
out.push('\n');
}
out.push_str("## Latest agent response\n\n");
if let Some(response) = brief.latest_agent_response.as_deref() {
out.push_str(&format!("> {}\n\n", markdown_quote(response)));
} else {
out.push_str("> No final assistant response was found.\n\n");
}
out.push_str("## Needs from you\n\n");
out.push_str(&format!("> {}\n\n", brief.needs_from_user));
out.push_str("## Actions\n\n");
out.push_str("- **Enter** — continue in this session\n");
out.push_str("- **/back** — return to the previous session\n");
out.push_str("- **/catchup next** — jump to the next unfinished handoff\n");
out.push_str("- **/resume** — browse all sessions normally\n");
out
}
fn state_path() -> Result<std::path::PathBuf> {
Ok(crate::storage::jcode_dir()?.join(CATCHUP_STATE_FILE))
}
fn load_seen_state() -> PersistedCatchupState {
let Ok(path) = state_path() else {
return PersistedCatchupState::default();
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn save_seen_state(state: &PersistedCatchupState) -> Result<()> {
let path = state_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(state)?)?;
Ok(())
}
fn is_attention_status(status: &SessionStatus) -> bool {
matches!(
status,
SessionStatus::Closed
| SessionStatus::Reloaded
| SessionStatus::Compacted
| SessionStatus::RateLimited
| SessionStatus::Crashed { .. }
| SessionStatus::Error { .. }
)
}
fn reason_and_tags(status: &SessionStatus) -> (String, Vec<String>) {
match status {
SessionStatus::Closed => (
"Finished and is ready for your next instruction.".to_string(),
vec!["completed".to_string(), "decision needed".to_string()],
),
SessionStatus::Reloaded => (
"Resumed after a reload and may need confirmation before continuing.".to_string(),
vec!["reloaded".to_string(), "review".to_string()],
),
SessionStatus::Compacted => (
"Compacted older context; review the latest result before continuing.".to_string(),
vec!["compacted".to_string(), "review".to_string()],
),
SessionStatus::RateLimited => (
"Paused by rate limiting; decide whether to retry here or move on.".to_string(),
vec!["waiting".to_string(), "rate limited".to_string()],
),
SessionStatus::Crashed { message } => (
message
.as_deref()
.map(|msg| format!("Failed and needs attention: {}", msg.trim()))
.unwrap_or_else(|| {
"Failed and may need intervention before continuing.".to_string()
}),
vec!["failed".to_string(), "intervention".to_string()],
),
SessionStatus::Error { message } => (
format!(
"Stopped with an error and needs attention: {}",
message.trim()
),
vec!["failed".to_string(), "error".to_string()],
),
SessionStatus::Active => ("Still active.".to_string(), vec!["active".to_string()]),
}
}
fn infer_needs_from_user(status: &SessionStatus, latest_response: Option<&str>) -> String {
if matches!(
status,
SessionStatus::Error { .. } | SessionStatus::Crashed { .. }
) {
return "Inspect the failure, decide whether to retry, and redirect follow-up work if needed."
.to_string();
}
let latest_lower = latest_response.unwrap_or_default().to_lowercase();
if [
"decide",
"choose",
"approve",
"which",
"option",
"what do you want",
]
.iter()
.any(|needle| latest_lower.contains(needle))
{
return "Review the proposed options and decide the next step for this session."
.to_string();
}
if matches!(status, SessionStatus::RateLimited) {
return "Decide whether to retry this session now or move on to the next catch-up."
.to_string();
}
"Continue here if you want to direct follow-up work, or jump to the next catch-up.".to_string()
}
fn collect_touched_files(session: &Session) -> Vec<String> {
let mut seen = HashSet::new();
let mut files = Vec::new();
for msg in &session.messages {
for block in &msg.content {
if let ContentBlock::ToolUse { input, .. } = block {
for key in ["file_path", "path"] {
let Some(value) = input.get(key).and_then(|value| value.as_str()) else {
continue;
};
let trimmed = value.trim();
if trimmed.is_empty() || !seen.insert(trimmed.to_string()) {
continue;
}
files.push(trimmed.to_string());
if files.len() >= 12 {
return files;
}
}
}
}
}
files
}
fn collect_tool_counts(session: &Session) -> Vec<(String, usize)> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for msg in &session.messages {
for block in &msg.content {
if let ContentBlock::ToolUse { name, .. } = block {
*counts.entry(name.clone()).or_default() += 1;
}
}
}
let mut counts: Vec<(String, usize)> = counts.into_iter().collect();
counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
counts
}
fn collect_activity_steps(session: &Session) -> Vec<String> {
let mut steps = Vec::new();
let mut last = String::new();
for msg in &session.messages {
for block in &msg.content {
let Some(step) = tool_use_step(block) else {
continue;
};
if step == last {
continue;
}
last = step.clone();
steps.push(step);
if steps.len() >= 6 {
return steps;
}
}
}
steps
}
fn tool_use_step(block: &ContentBlock) -> Option<String> {
let ContentBlock::ToolUse { name, input, .. } = block else {
return None;
};
let obj = input.as_object();
match name.as_str() {
"agentgrep" | "grep" | "glob" | "ls" | "codesearch" | "session_search" => {
Some("Searched code and session context".to_string())
}
"read" => Some(
obj.and_then(|map| map.get("file_path").and_then(|v| v.as_str()))
.map(|path| format!("Inspected `{}`", path.trim()))
.unwrap_or_else(|| "Inspected files".to_string()),
),
"edit" | "multiedit" | "write" | "patch" | "apply_patch" => Some(
obj.and_then(|map| map.get("file_path").and_then(|v| v.as_str()))
.map(|path| format!("Updated `{}`", path.trim()))
.unwrap_or_else(|| "Edited files".to_string()),
),
"bash" => {
let command = obj
.and_then(|map| map.get("command").and_then(|v| v.as_str()))
.unwrap_or_default()
.trim();
let lower = command.to_lowercase();
if lower.contains("cargo test")
|| lower.contains("pytest")
|| lower.contains("npm test")
|| lower.contains("pnpm test")
|| lower.contains("go test")
{
Some(format!("Ran tests{}", summarize_shell_suffix(command)))
} else if lower.contains("cargo build")
|| lower.contains("npm run build")
|| lower.contains("pnpm build")
|| lower.contains("go build")
{
Some(format!(
"Built the project{}",
summarize_shell_suffix(command)
))
} else {
Some(format!(
"Ran shell command{}",
summarize_shell_suffix(command)
))
}
}
"communicate" => Some("Coordinated with other agents".to_string()),
"subagent" => Some("Spawned a subagent".to_string()),
"memory" => Some("Queried memory context".to_string()),
"side_panel" | "todo" | "todoread" | "todowrite" | "initiative" => None,
other => Some(format!("Used `{}`", other)),
}
}
fn summarize_shell_suffix(command: &str) -> String {
let trimmed = command.trim();
if trimmed.is_empty() {
String::new()
} else {
format!(" · `{}`", truncate(trimmed, 56))
}
}
fn collect_validation_notes(rendered: &[crate::session::RenderedMessage]) -> Vec<String> {
let mut notes = Vec::new();
for msg in rendered.iter().rev() {
if msg.role != "tool" {
continue;
}
let Some(tool) = msg.tool_data.as_ref() else {
continue;
};
if tool.name != "bash" {
continue;
}
let command = tool
.input
.get("command")
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim();
if command.is_empty() {
continue;
}
let lower = command.to_lowercase();
let label = if lower.contains("test") {
"tests"
} else if lower.contains("build") {
"build"
} else {
continue;
};
let ok = !looks_like_error(&msg.content);
notes.push(format!(
"{} {}: `{}`",
if ok { "" } else { "" },
label,
truncate(command, 64)
));
if notes.len() >= 3 {
break;
}
}
notes.reverse();
notes
}
fn looks_like_error(text: &str) -> bool {
let trimmed = text.trim_start().to_lowercase();
trimmed.starts_with("error:") || trimmed.starts_with("failed:") || trimmed.contains("exit 1")
}
fn status_icon(status: &SessionStatus) -> &'static str {
match status {
SessionStatus::Closed => "🟢",
SessionStatus::Reloaded => "🔄",
SessionStatus::Compacted => "🟠",
SessionStatus::RateLimited => "",
SessionStatus::Crashed { .. } | SessionStatus::Error { .. } => "🔴",
SessionStatus::Active => "",
}
}
fn status_label(status: &SessionStatus) -> &'static str {
match status {
SessionStatus::Closed => "completed",
SessionStatus::Reloaded => "reloaded",
SessionStatus::Compacted => "compacted",
SessionStatus::RateLimited => "waiting",
SessionStatus::Crashed { .. } | SessionStatus::Error { .. } => "failed",
SessionStatus::Active => "active",
}
}
fn format_time_ago(updated_at: DateTime<Utc>) -> String {
let delta = Utc::now().signed_duration_since(updated_at);
if delta.num_seconds() < 60 {
format!("{}s ago", delta.num_seconds().max(0))
} else if delta.num_minutes() < 60 {
format!("{}m ago", delta.num_minutes())
} else if delta.num_hours() < 24 {
format!("{}h ago", delta.num_hours())
} else {
format!("{}d ago", delta.num_days())
}
}
fn truncate(text: &str, max_chars: usize) -> String {
let trimmed = text.trim();
if trimmed.chars().count() <= max_chars {
return trimmed.to_string();
}
let mut out = trimmed
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>();
out.push('…');
out
}
fn markdown_quote(text: &str) -> String {
truncate(text.replace('\n', " ").trim(), 600)
}
fn mermaid_escape(text: &str) -> String {
text.replace('"', "'")
.replace('\n', "<br/>")
.replace(':', " -")
}
#[cfg(test)]
mod tests {
use super::{build_brief, needs_catchup_with_seen, render_markdown};
use crate::message::{ContentBlock, Role};
use crate::session::{Session, SessionStatus};
#[test]
fn needs_catchup_requires_attention_status_and_newer_than_seen() {
assert!(needs_catchup_with_seen(10, Some(9), &SessionStatus::Closed));
assert!(!needs_catchup_with_seen(
10,
Some(10),
&SessionStatus::Closed
));
assert!(!needs_catchup_with_seen(10, None, &SessionStatus::Active));
}
#[test]
fn render_markdown_includes_key_sections() {
let mut session = Session::create(None, Some("catchup".to_string()));
session.short_name = Some("fox".to_string());
session.status = SessionStatus::Closed;
session.add_message(
Role::User,
vec![ContentBlock::Text {
text: "Implement the catch up side panel".to_string(),
cache_control: None,
}],
);
session.add_message(
Role::Assistant,
vec![ContentBlock::Text {
text: "Searched the relevant session picker code.".to_string(),
cache_control: None,
}],
);
session.add_message(
Role::Assistant,
vec![ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "read".to_string(),
input: serde_json::json!({"file_path": "src/tui/session_picker.rs"}),
thought_signature: None,
}],
);
session.add_message(
Role::Assistant,
vec![ContentBlock::Text {
text: "Implemented the first pass and left a few follow-up notes.".to_string(),
cache_control: None,
}],
);
let brief = build_brief(&session);
let markdown = render_markdown(&session, Some("session_otter"), Some((1, 3)), &brief);
assert!(markdown.contains("# Catch Up"));
assert!(markdown.contains("## Your last prompt"));
assert!(markdown.contains("## What happened"));
assert!(markdown.contains("## Latest agent response"));
assert!(markdown.contains("## Needs from you"));
assert!(markdown.contains("```mermaid"));
}
}
+953
View File
@@ -0,0 +1,953 @@
use crate::ambient_runner::AmbientRunnerHandle;
use crate::config::SafetyConfig;
use crate::logging;
use async_trait::async_trait;
use std::sync::Arc;
#[async_trait]
pub trait MessageChannel: Send + Sync {
fn name(&self) -> &str;
fn is_send_enabled(&self) -> bool;
fn is_reply_enabled(&self) -> bool;
async fn send(&self, text: &str) -> anyhow::Result<()>;
async fn reply_loop(&self, runner: AmbientRunnerHandle);
}
#[derive(Clone)]
pub struct ChannelRegistry {
channels: Vec<Arc<dyn MessageChannel>>,
}
impl ChannelRegistry {
pub fn from_config(config: &SafetyConfig) -> Self {
let mut channels: Vec<Arc<dyn MessageChannel>> = Vec::new();
if config.telegram_enabled
&& let (Some(token), Some(chat_id)) = (
config.telegram_bot_token.clone(),
config.telegram_chat_id.clone(),
)
{
logging::info(&format!(
"registering telegram notification channel reply_enabled={}",
config.telegram_reply_enabled
));
channels.push(Arc::new(TelegramChannel::new(
token,
chat_id,
config.telegram_reply_enabled,
)));
}
if config.discord_enabled
&& let (Some(token), Some(channel_id)) = (
config.discord_bot_token.clone(),
config.discord_channel_id.clone(),
)
{
logging::info(&format!(
"registering discord notification channel reply_enabled={}",
config.discord_reply_enabled
));
channels.push(Arc::new(DiscordChannel::new(
token,
channel_id,
config.discord_reply_enabled,
config.discord_bot_user_id.clone(),
)));
}
if config.jade_relay_enabled {
match (
config.jade_relay_api_base.clone(),
config.jade_relay_token.clone(),
config.jade_relay_session_id.clone(),
) {
(Some(api_base), Some(token), Some(session_id)) => {
// user_id defaults to the token id when not explicitly set.
let user_id = config
.jade_relay_user_id
.clone()
.or_else(|| config.jade_relay_token_id.clone())
.unwrap_or_else(|| "default".to_string());
logging::info(&format!(
"registering jade relay channel user={} session={} reply_enabled={}",
user_id, session_id, config.jade_relay_reply_enabled
));
channels.push(Arc::new(JadeRelayChannel::new(
api_base,
token,
config.jade_relay_token_id.clone(),
user_id,
session_id,
config.jade_relay_reply_enabled,
)));
}
_ => {
logging::warn(
"jade_relay_enabled but api_base/token/session_id incomplete; skipping",
);
}
}
}
logging::debug(&format!(
"channel registry initialized channel_count={}",
channels.len()
));
Self { channels }
}
pub fn send_all(&self, text: &str) {
if tokio::runtime::Handle::try_current().is_err() {
logging::warn("skipping channel send_all because no Tokio runtime is active");
return;
}
for ch in self.channels.iter().filter(|c| c.is_send_enabled()) {
let ch = Arc::clone(ch);
let text = text.to_string();
tokio::spawn(async move {
logging::debug(&format!("sending notification via {}", ch.name()));
if let Err(e) = ch.send(&text).await {
logging::error(&format!("{} notification failed: {}", ch.name(), e));
}
});
}
}
pub fn spawn_reply_loops(&self, runner: &AmbientRunnerHandle) {
for ch in self.channels.iter().filter(|c| c.is_reply_enabled()) {
let ch = Arc::clone(ch);
let runner = runner.clone();
tokio::spawn(async move {
logging::info(&format!("{} reply loop spawned", ch.name()));
ch.reply_loop(runner).await;
});
}
}
pub fn channel_names(&self) -> Vec<String> {
self.channels.iter().map(|c| c.name().to_string()).collect()
}
pub fn find_by_name(&self, name: &str) -> Option<Arc<dyn MessageChannel>> {
let channel = self.channels.iter().find(|c| c.name() == name).cloned();
if channel.is_none() {
logging::debug(&format!("channel lookup missed name={name}"));
}
channel
}
pub fn send_enabled(&self) -> Vec<Arc<dyn MessageChannel>> {
self.channels
.iter()
.filter(|c| c.is_send_enabled())
.cloned()
.collect()
}
}
// ---------------------------------------------------------------------------
// Telegram channel
// ---------------------------------------------------------------------------
pub struct TelegramChannel {
token: String,
chat_id: String,
reply_enabled: bool,
client: reqwest::Client,
}
impl TelegramChannel {
pub fn new(token: String, chat_id: String, reply_enabled: bool) -> Self {
Self {
token,
chat_id,
reply_enabled,
client: crate::provider::shared_http_client(),
}
}
}
#[async_trait]
impl MessageChannel for TelegramChannel {
fn name(&self) -> &str {
"telegram"
}
fn is_send_enabled(&self) -> bool {
true
}
fn is_reply_enabled(&self) -> bool {
self.reply_enabled
}
async fn send(&self, text: &str) -> anyhow::Result<()> {
logging::debug(&format!(
"sending telegram notification bytes={}",
text.len()
));
crate::telegram::send_message(&self.client, &self.token, &self.chat_id, text).await
}
async fn reply_loop(&self, runner: AmbientRunnerHandle) {
let mut offset: Option<i64> = None;
loop {
match crate::telegram::get_updates(&self.client, &self.token, offset, 30).await {
Ok(updates) => {
if !updates.is_empty() {
logging::debug(&format!(
"telegram reply loop received update_count={}",
updates.len()
));
}
for update in updates {
offset = Some(update.update_id + 1);
let msg = match update.message {
Some(m) => m,
None => continue,
};
if msg.chat.id.to_string() != self.chat_id {
continue;
}
let text = match msg.text {
Some(t) => t,
None => continue,
};
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) {
let (approved, message) =
crate::notifications::parse_permission_reply(trimmed);
if let Err(e) = crate::safety::record_permission_via_file(
&req_id,
approved,
"telegram_reply",
message,
) {
logging::error(&format!(
"Failed to record permission from Telegram for {}: {}",
req_id, e
));
} else {
logging::info(&format!(
"Permission {} via Telegram: {}",
if approved { "approved" } else { "denied" },
req_id
));
let _ = self
.send(&format!(
"✅ Permission {} for `{}`",
if approved { "approved" } else { "denied" },
req_id
))
.await;
}
} else {
let injected = runner.inject_message(trimmed, "telegram").await;
logging::info(&format!(
"telegram reply injected into session injected={}",
injected
));
let ack = if injected {
format!("💬 Message sent to active session: _{}_", trimmed)
} else {
format!("📋 Message queued, waking agent: _{}_", trimmed)
};
let _ = self.send(&ack).await;
}
}
}
Err(e) => {
logging::error(&format!("Telegram poll error: {}", e));
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Discord channel
// ---------------------------------------------------------------------------
pub struct DiscordChannel {
token: String,
channel_id: String,
reply_enabled: bool,
bot_user_id: Option<String>,
client: reqwest::Client,
}
impl DiscordChannel {
pub fn new(
token: String,
channel_id: String,
reply_enabled: bool,
bot_user_id: Option<String>,
) -> Self {
Self {
token,
channel_id,
reply_enabled,
bot_user_id,
client: crate::provider::shared_http_client(),
}
}
async fn poll_messages(&self, after: Option<&str>) -> anyhow::Result<Vec<DiscordMessage>> {
logging::debug(&format!(
"polling discord messages after_present={}",
after.is_some()
));
let mut url = format!(
"https://discord.com/api/v10/channels/{}/messages?limit=10",
self.channel_id
);
if let Some(after_id) = after {
url.push_str(&format!("&after={}", after_id));
}
let resp = self
.client
.get(&url)
.header("Authorization", format!("Bot {}", self.token))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
logging::warn(&format!("discord message poll returned status={status}"));
anyhow::bail!("Discord messages error ({}): {}", status, body);
}
let messages: Vec<DiscordMessage> = resp.json().await?;
logging::debug(&format!(
"discord message poll returned count={}",
messages.len()
));
Ok(messages)
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct DiscordMessage {
pub id: String,
pub content: String,
pub author: DiscordAuthor,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct DiscordAuthor {
pub id: String,
pub bot: Option<bool>,
}
#[async_trait]
impl MessageChannel for DiscordChannel {
fn name(&self) -> &str {
"discord"
}
fn is_send_enabled(&self) -> bool {
true
}
fn is_reply_enabled(&self) -> bool {
self.reply_enabled
}
async fn send(&self, text: &str) -> anyhow::Result<()> {
let url = format!(
"https://discord.com/api/v10/channels/{}/messages",
self.channel_id
);
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bot {}", self.token))
.json(&serde_json::json!({ "content": text }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Discord API error ({}): {}", status, body);
}
logging::info("Discord notification sent");
Ok(())
}
async fn reply_loop(&self, runner: AmbientRunnerHandle) {
let mut last_seen_id: Option<String> = None;
// Get the latest message ID on startup so we don't replay old messages
match self.poll_messages(None).await {
Ok(msgs) => {
if let Some(latest) = msgs.first() {
last_seen_id = Some(latest.id.clone());
}
}
Err(e) => {
logging::error(&format!("Discord initial poll error: {}", e));
}
}
loop {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
match self.poll_messages(last_seen_id.as_deref()).await {
Ok(msgs) => {
// Discord returns newest first, reverse for chronological order
let mut msgs = msgs;
msgs.reverse();
for msg in msgs {
last_seen_id = Some(msg.id.clone());
// Skip messages from bots (including ourselves)
if msg.author.bot.unwrap_or(false) {
continue;
}
// If we know our bot user ID, also skip our own messages
if let Some(ref bot_id) = self.bot_user_id
&& msg.author.id == *bot_id
{
continue;
}
let trimmed = msg.content.trim();
if trimmed.is_empty() {
continue;
}
if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) {
let (approved, message) =
crate::notifications::parse_permission_reply(trimmed);
if let Err(e) = crate::safety::record_permission_via_file(
&req_id,
approved,
"discord_reply",
message,
) {
logging::error(&format!(
"Failed to record permission from Discord for {}: {}",
req_id, e
));
} else {
logging::info(&format!(
"Permission {} via Discord: {}",
if approved { "approved" } else { "denied" },
req_id
));
let _ = self
.send(&format!(
"✅ Permission {} for `{}`",
if approved { "approved" } else { "denied" },
req_id
))
.await;
}
} else {
let injected = runner.inject_message(trimmed, "discord").await;
logging::info(&format!(
"discord reply injected into session injected={}",
injected
));
let ack = if injected {
format!("💬 Message sent to active session: *{}*", trimmed)
} else {
format!("📋 Message queued, waking agent: *{}*", trimmed)
};
let _ = self.send(&ack).await;
}
}
}
Err(e) => {
logging::error(&format!("Discord poll error: {}", e));
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Jade cloud relay channel
// ---------------------------------------------------------------------------
/// Remote control via the Jade cloud relay (an append-only per-session event
/// log in AWS). Unlike the WebSocket gateway, nothing listens on this machine:
/// the laptop only makes outbound long-poll requests, so there is no inbound
/// port to attack. A cloud client posts `prompt` events; this channel injects
/// them into the live session and posts the agent's reply back as a `response`
/// event for the cloud client to read.
pub struct JadeRelayChannel {
/// API base URL, normalized to end with a single '/'.
api_base: String,
token: String,
token_id: Option<String>,
user_id: String,
session_id: String,
reply_enabled: bool,
client: reqwest::Client,
}
impl JadeRelayChannel {
pub fn new(
api_base: String,
token: String,
token_id: Option<String>,
user_id: String,
session_id: String,
reply_enabled: bool,
) -> Self {
let api_base = if api_base.ends_with('/') {
api_base
} else {
format!("{}/", api_base)
};
Self {
api_base,
token,
token_id,
user_id,
session_id,
reply_enabled,
client: crate::provider::shared_http_client(),
}
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.api_base, path.trim_start_matches('/'))
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let mut req = req.header("Authorization", format!("Bearer {}", self.token));
if let Some(id) = &self.token_id {
req = req.header("x-jade-token-id", id);
}
req
}
/// Register/heartbeat this device so the cloud can show it as online.
async fn heartbeat(&self, device_id: &str) {
let body = serde_json::json!({
"user_id": self.user_id,
"device_id": device_id,
"label": device_id,
"platform": std::env::consts::OS,
});
let req = self.auth(self.client.post(self.url("v1/devices")).json(&body));
if let Err(e) = req.send().await {
logging::debug(&format!("jade relay heartbeat failed: {}", e));
}
}
/// Long-poll for new prompt events after `after`. Returns (events, next_after).
/// `wait` is the server-side long-poll window in seconds (capped at 25 by the relay).
async fn poll_prompts(&self, after: i64, wait: u32) -> anyhow::Result<(Vec<RelayEvent>, i64)> {
let session = urlencoding_encode(&self.session_id);
let url = self.url(&format!(
"v1/sessions/{}/events?user_id={}&after={}&types=prompt&wait={}",
session,
urlencoding_encode(&self.user_id),
after,
wait
));
let resp = self.auth(self.client.get(&url)).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("jade relay poll error ({}): {}", status, body);
}
let parsed: RelayEventsResponse = resp.json().await?;
Ok((parsed.events, parsed.next_after))
}
/// Post a response event back to the relay for the cloud client to read.
async fn post_response(&self, text: &str, request_seq: i64) -> anyhow::Result<()> {
let session = urlencoding_encode(&self.session_id);
let body = serde_json::json!({
"user_id": self.user_id,
"type": "response",
"text": text,
"request_seq": request_seq,
"origin": "jcode",
});
let resp = self
.auth(
self.client
.post(self.url(&format!("v1/sessions/{}/events", session)))
.json(&body),
)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let detail = resp.text().await.unwrap_or_default();
anyhow::bail!("jade relay post error ({}): {}", status, detail);
}
Ok(())
}
}
#[derive(Debug, serde::Deserialize)]
struct RelayEventsResponse {
#[serde(default)]
events: Vec<RelayEvent>,
#[serde(default)]
next_after: i64,
}
#[derive(Debug, serde::Deserialize)]
struct RelayEvent {
#[serde(default)]
seq: i64,
#[serde(default)]
text: Option<String>,
}
/// Minimal percent-encoding for path/query segments (alnum and -_.~ pass through).
fn urlencoding_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
#[async_trait]
impl MessageChannel for JadeRelayChannel {
fn name(&self) -> &str {
"jade_relay"
}
fn is_send_enabled(&self) -> bool {
true
}
fn is_reply_enabled(&self) -> bool {
// Inbound Jade relay prompts are delivered by server::jade_relay so they
// work even when ambient mode is disabled and target the configured live
// Jcode session directly. Keep this channel for outbound notifications
// only; otherwise ambient mode would start a second poller.
let _configured_for_server_listener = self.reply_enabled;
false
}
async fn send(&self, text: &str) -> anyhow::Result<()> {
// Cloud notifications (e.g. ambient cycle summaries) are posted as a
// response event with request_seq=0 (not tied to a specific prompt).
self.post_response(text, 0).await
}
async fn reply_loop(&self, runner: AmbientRunnerHandle) {
let host = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "laptop".to_string());
let device_id = format!("jcode-{}", host);
logging::info(&format!(
"jade relay reply loop started channel={}/{}",
self.user_id, self.session_id
));
// Start after the latest existing prompt so we don't replay history.
let mut after: i64 = match self.poll_prompts(0, 0).await {
Ok((_, next)) => next,
Err(e) => {
logging::error(&format!("jade relay init poll failed: {}", e));
0
}
};
let mut last_heartbeat = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(60))
.unwrap_or_else(std::time::Instant::now);
loop {
if last_heartbeat.elapsed() >= std::time::Duration::from_secs(30) {
self.heartbeat(&device_id).await;
last_heartbeat = std::time::Instant::now();
}
match self.poll_prompts(after, 20).await {
Ok((events, next_after)) => {
after = next_after;
for ev in events {
let text = ev.text.unwrap_or_default();
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) {
let (approved, message) =
crate::notifications::parse_permission_reply(trimmed);
if let Err(e) = crate::safety::record_permission_via_file(
&req_id,
approved,
"jade_relay",
message,
) {
logging::error(&format!(
"Failed to record permission from jade relay for {}: {}",
req_id, e
));
} else {
let _ = self
.post_response(
&format!(
"Permission {} for {}",
if approved { "approved" } else { "denied" },
req_id
),
ev.seq,
)
.await;
}
continue;
}
let injected = runner.inject_message(trimmed, "jade_relay").await;
logging::info(&format!(
"jade relay prompt injected seq={} injected={}",
ev.seq, injected
));
let ack = if injected {
"Message delivered to active session."
} else {
"Message queued; waking agent."
};
if let Err(e) = self.post_response(ack, ev.seq).await {
logging::error(&format!("jade relay ack post failed: {}", e));
}
}
}
Err(e) => {
logging::error(&format!("jade relay poll error: {}", e));
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discord_message_parse() {
let json = r#"{
"id": "123456",
"content": "hello agent",
"author": {"id": "789", "bot": false}
}"#;
let msg: DiscordMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.id, "123456");
assert_eq!(msg.content, "hello agent");
assert!(!msg.author.bot.unwrap());
}
#[test]
fn test_discord_bot_message_parse() {
let json = r#"{
"id": "999",
"content": "bot response",
"author": {"id": "111", "bot": true}
}"#;
let msg: DiscordMessage = serde_json::from_str(json).unwrap();
assert!(msg.author.bot.unwrap());
}
#[test]
fn test_relay_events_parse() {
let json = r#"{
"events": [
{"seq": 5, "type": "prompt", "text": "run the tests"},
{"seq": 6, "type": "prompt", "text": "now lint"}
],
"next_after": 6
}"#;
let parsed: RelayEventsResponse = serde_json::from_str(json).unwrap();
assert_eq!(parsed.events.len(), 2);
assert_eq!(parsed.events[0].seq, 5);
assert_eq!(parsed.events[0].text.as_deref(), Some("run the tests"));
assert_eq!(parsed.next_after, 6);
}
#[test]
fn test_relay_events_empty() {
let json = r#"{"events": [], "next_after": 0}"#;
let parsed: RelayEventsResponse = serde_json::from_str(json).unwrap();
assert!(parsed.events.is_empty());
assert_eq!(parsed.next_after, 0);
}
#[test]
fn test_relay_url_encoding() {
assert_eq!(urlencoding_encode("sess-relay-test"), "sess-relay-test");
assert_eq!(urlencoding_encode("a/b c"), "a%2Fb%20c");
assert_eq!(urlencoding_encode("user.name~1_2"), "user.name~1_2");
}
#[test]
fn test_relay_url_join() {
let ch = JadeRelayChannel::new(
"https://example.com/api".to_string(),
"tok".to_string(),
Some("jeremy".to_string()),
"jeremy".to_string(),
"sess-1".to_string(),
true,
);
assert_eq!(ch.url("v1/devices"), "https://example.com/api/v1/devices");
assert_eq!(ch.url("/v1/devices"), "https://example.com/api/v1/devices");
}
#[test]
fn test_relay_registry_wiring() {
// Disabled: not registered.
let cfg = SafetyConfig::default();
let reg = ChannelRegistry::from_config(&cfg);
assert!(!reg.channel_names().iter().any(|n| n == "jade_relay"));
// Enabled but incomplete: skipped with a warning.
let mut cfg = SafetyConfig {
jade_relay_enabled: true,
..SafetyConfig::default()
};
let reg = ChannelRegistry::from_config(&cfg);
assert!(!reg.channel_names().iter().any(|n| n == "jade_relay"));
// Enabled and complete: registered.
cfg.jade_relay_api_base = Some("https://example.com/".to_string());
cfg.jade_relay_token = Some("tok".to_string());
cfg.jade_relay_session_id = Some("sess-1".to_string());
let reg = ChannelRegistry::from_config(&cfg);
assert!(reg.channel_names().iter().any(|n| n == "jade_relay"));
}
/// Live end-to-end test against the real Jade relay. Ignored by default;
/// run with the relay env vars set:
/// JADE_RELAY_API_BASE, JADE_RELAY_TOKEN, JADE_RELAY_TOKEN_ID,
/// JADE_RELAY_USER_ID, JADE_RELAY_SESSION_ID
/// cargo test -p jcode-app-core relay_live -- --ignored --nocapture
#[tokio::test]
#[ignore = "requires live Jade relay credentials"]
async fn test_relay_live_roundtrip() {
let api_base = match std::env::var("JADE_RELAY_API_BASE") {
Ok(v) => v,
Err(_) => {
eprintln!("skipping: JADE_RELAY_API_BASE not set");
return;
}
};
let token = std::env::var("JADE_RELAY_TOKEN").expect("JADE_RELAY_TOKEN");
let token_id = std::env::var("JADE_RELAY_TOKEN_ID").ok();
let user_id = std::env::var("JADE_RELAY_USER_ID").unwrap_or_else(|_| "jeremy".to_string());
let session_id = std::env::var("JADE_RELAY_SESSION_ID")
.unwrap_or_else(|_| format!("rust-live-{}", chrono::Utc::now().timestamp()));
let ch = JadeRelayChannel::new(
api_base,
token,
token_id.clone(),
user_id.clone(),
session_id.clone(),
true,
);
// 1) heartbeat (device register)
ch.heartbeat("jcode-test-device").await;
// 2) baseline cursor: no prompts yet
let (events, after) = ch.poll_prompts(0, 0).await.expect("baseline poll");
eprintln!("baseline: {} events, next_after={}", events.len(), after);
// 3) simulate a cloud client posting a prompt by POSTing a prompt event
let prompt_text = format!(
"hello from rust live test {}",
chrono::Utc::now().timestamp()
);
let prompt_body = serde_json::json!({
"user_id": user_id,
"type": "prompt",
"text": prompt_text,
"origin": "rust-test-client",
});
let resp = ch
.auth(
ch.client
.post(ch.url(&format!(
"v1/sessions/{}/events",
urlencoding_encode(&session_id)
)))
.json(&prompt_body),
)
.send()
.await
.expect("post prompt");
assert!(
resp.status().is_success(),
"post prompt status {}",
resp.status()
);
// 4) the channel polls and sees the prompt
let (events, after2) = ch.poll_prompts(after, 5).await.expect("poll after prompt");
assert!(!events.is_empty(), "expected at least one prompt event");
let prompt_ev = events
.iter()
.find(|e| e.text.as_deref() == Some(prompt_text.as_str()))
.expect("our prompt event present");
eprintln!("received prompt seq={} after2={}", prompt_ev.seq, after2);
// 5) the channel posts a response tied to that prompt's seq
let reply = format!("rust live reply to seq {}", prompt_ev.seq);
ch.post_response(&reply, prompt_ev.seq)
.await
.expect("post response");
// 6) verify the response is visible (poll all event types via raw GET)
let verify_url = ch.url(&format!(
"v1/sessions/{}/events?user_id={}&after=0&types=response&wait=5",
urlencoding_encode(&session_id),
urlencoding_encode(&user_id)
));
let verify: RelayEventsResponse = ch
.auth(ch.client.get(&verify_url))
.send()
.await
.expect("verify get")
.json()
.await
.expect("verify json");
assert!(
verify
.events
.iter()
.any(|e| e.text.as_deref() == Some(reply.as_str())),
"response event should be readable back from the relay"
);
eprintln!("LIVE ROUNDTRIP OK: prompt -> poll -> response verified");
}
}
+727
View File
@@ -0,0 +1,727 @@
//! External-auth-source review and auto-import flow.
//!
//! Discovers credentials left behind by other tools (Claude Code, Codex,
//! Copilot, Cursor, Gemini CLI, ...), asks the user to approve trusting them,
//! and imports approved sources. This is provider/auth domain logic that
//! depends only on core modules (`auth`, `config`, `provider`,
//! `provider_catalog`), so it lives in the core layer and can be driven by
//! both the CLI login flow and the TUI auth UI.
use anyhow::Result;
use std::io::{self, IsTerminal, Write};
use crate::auth;
pub fn can_prompt_for_external_auth() -> bool {
std::io::stdin().is_terminal()
&& std::io::stderr().is_terminal()
&& std::env::var("JCODE_NON_INTERACTIVE").is_err()
}
pub fn external_auth_blocked_message(
provider_name: &str,
source_name: &str,
path: &std::path::Path,
login_hint: &str,
) -> String {
format!(
"Found existing {} credentials from {} at {} but jcode will not read them without confirmation. Re-run in an interactive terminal to approve this auth source for future jcode sessions, or run `{}`.",
provider_name,
source_name,
path.display(),
login_hint
)
}
pub fn prompt_to_trust_external_auth(
provider_name: &str,
source_name: &str,
path: &std::path::Path,
) -> Result<bool> {
eprintln!();
eprintln!(
"Found existing {} credentials from {} at {}.",
provider_name,
source_name,
path.display()
);
eprintln!("jcode will only read that source in place after you approve it.");
eprintln!("It will not move, delete, or rewrite the original auth there.");
eprint!("Trust this auth source for future jcode sessions? [y/N]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(matches!(
input.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ExternalAuthReviewAction {
SharedExternal(auth::external::ExternalAuthSource),
CodexLegacy,
ClaudeCode,
/// Claude Code's native credentials (macOS Keychain item or
/// `CLAUDE_CODE_OAUTH_TOKEN` env var), which have no stable on-disk path.
ClaudeCodeNative,
GeminiCli,
Copilot(auth::copilot::ExternalCopilotAuthSource),
Cursor(auth::cursor::ExternalCursorAuthSource),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAuthReviewCandidate {
pub(crate) provider_summary: String,
pub(crate) source_name: String,
pub(crate) path: std::path::PathBuf,
action: ExternalAuthReviewAction,
}
// Read-only accessors. Kept available outside tests so onboarding/UI can
// summarize detected import candidates (e.g. for the first-run welcome card).
impl ExternalAuthReviewCandidate {
pub fn provider_summary(&self) -> &str {
&self.provider_summary
}
pub fn source_name(&self) -> &str {
&self.source_name
}
/// Build a synthetic candidate for tests / UI fixtures. The resulting
/// candidate points at the legacy Codex action so it can be summarized and
/// rendered, but is not expected to actually import successfully.
#[doc(hidden)]
pub fn fixture(provider_summary: impl Into<String>, source_name: impl Into<String>) -> Self {
Self {
provider_summary: provider_summary.into(),
source_name: source_name.into(),
path: std::path::PathBuf::from("/dev/null"),
action: ExternalAuthReviewAction::CodexLegacy,
}
}
}
impl ExternalAuthReviewCandidate {
/// Coarse telemetry `(provider, method)` labels for the providers this
/// candidate activates on a successful import. Used by the onboarding flow
/// to record `auth_success` so auto-imported logins show up in the
/// activation funnel (they previously did not, because auto-import never
/// flows through the manual `pending_login` telemetry path).
///
/// The method is reported as `"import"` so import-driven activation can be
/// distinguished from manual login in the funnel.
pub fn telemetry_auth_labels(&self) -> Vec<(&'static str, &'static str)> {
const METHOD: &str = "import";
match &self.action {
ExternalAuthReviewAction::CodexLegacy => vec![("openai", METHOD)],
ExternalAuthReviewAction::ClaudeCode => vec![("claude", METHOD)],
ExternalAuthReviewAction::ClaudeCodeNative => vec![("claude", METHOD)],
ExternalAuthReviewAction::GeminiCli => vec![("gemini", METHOD)],
ExternalAuthReviewAction::Copilot(_) => vec![("copilot", METHOD)],
ExternalAuthReviewAction::Cursor(_) => vec![("cursor", METHOD)],
ExternalAuthReviewAction::SharedExternal(source) => {
auth::external::source_provider_labels(*source)
.into_iter()
.filter_map(|label| {
telemetry_provider_id_for_label(label).map(|id| (id, METHOD))
})
.collect()
}
}
}
}
/// Map a human-facing provider label (as produced by
/// [`auth::external::source_provider_labels`]) to the canonical telemetry
/// provider id used by the activation funnel.
fn telemetry_provider_id_for_label(label: &str) -> Option<&'static str> {
match label {
"OpenAI/Codex" => Some("openai"),
"Claude" => Some("claude"),
"Gemini" => Some("gemini"),
"Antigravity" => Some("antigravity"),
"GitHub Copilot" => Some("copilot"),
"OpenRouter/API-key providers" => Some("openrouter"),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAuthAutoImportOutcome {
pub imported: usize,
pub messages: Vec<String>,
/// Coarse `(provider, method)` telemetry labels for each provider that was
/// successfully imported, so callers can record `auth_success` for the
/// activation funnel. May contain more entries than `imported` when a
/// single source carries multiple providers.
pub imported_auth_labels: Vec<(&'static str, &'static str)>,
}
impl ExternalAuthAutoImportOutcome {
pub fn render_markdown(&self) -> String {
if self.messages.is_empty() {
return "No external logins were imported.".to_string();
}
// Messages are tagged with a leading "✓"/"✕" marker by the importer.
let imported: Vec<&String> = self
.messages
.iter()
.filter(|m| m.starts_with('✓'))
.collect();
let skipped: Vec<&String> = self
.messages
.iter()
.filter(|m| m.starts_with('✕'))
.collect();
let mut out = String::from("**Logins imported**\n");
out.push('\n');
if imported.is_empty() {
out.push_str("No logins could be imported.");
} else {
out.push_str(&format!(
"Reusing {} existing login{}:",
imported.len(),
if imported.len() == 1 { "" } else { "s" }
));
}
for line in &imported {
out.push_str("\n- ");
out.push_str(line.trim_start_matches('✓').trim());
}
if !skipped.is_empty() {
out.push_str(&format!(
"\n\nSkipped {} source{}:",
skipped.len(),
if skipped.len() == 1 { "" } else { "s" }
));
for line in &skipped {
out.push_str("\n- ");
out.push_str(line.trim_start_matches('✕').trim());
}
}
out
}
}
pub fn pending_external_auth_review_candidates() -> Result<Vec<ExternalAuthReviewCandidate>> {
let mut candidates = Vec::new();
for source in auth::external::unconsented_sources() {
let provider_summary = auth::external::source_provider_labels(source).join(", ");
if provider_summary.is_empty() {
continue;
}
candidates.push(ExternalAuthReviewCandidate {
provider_summary,
source_name: source.display_name().to_string(),
path: source.path()?,
action: ExternalAuthReviewAction::SharedExternal(source),
});
}
if auth::codex::has_unconsented_legacy_credentials() {
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "OpenAI/Codex".to_string(),
source_name: "Codex auth.json".to_string(),
path: auth::codex::legacy_auth_file_path()?,
action: ExternalAuthReviewAction::CodexLegacy,
});
}
if let Some(source) = auth::claude::has_unconsented_external_auth()
&& matches!(source, auth::claude::ExternalClaudeAuthSource::ClaudeCode)
{
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "Claude".to_string(),
source_name: source.display_name().to_string(),
path: source.path()?,
action: ExternalAuthReviewAction::ClaudeCode,
});
}
// Claude Code's native credentials live in the macOS Keychain (or the
// CLAUDE_CODE_OAUTH_TOKEN env var), not in the JSON file. Offer them when
// the JSON file was not already detected above, so macOS users (where the
// file usually does not exist) can still import their Claude login.
if !auth::claude::native_source_allowed()
&& !candidates
.iter()
.any(|candidate| matches!(candidate.action, ExternalAuthReviewAction::ClaudeCode))
&& auth::claude::native_credentials_present()
{
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "Claude".to_string(),
source_name: auth::claude::native_source_display_name().to_string(),
path: auth::claude::native_source_path_hint(),
action: ExternalAuthReviewAction::ClaudeCodeNative,
});
}
if auth::gemini::has_unconsented_cli_auth() {
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "Gemini".to_string(),
source_name: "Gemini CLI".to_string(),
path: auth::gemini::gemini_cli_oauth_path()?,
action: ExternalAuthReviewAction::GeminiCli,
});
}
if let Some(source) = auth::copilot::has_unconsented_external_auth()
&& !matches!(
source,
auth::copilot::ExternalCopilotAuthSource::OpenCodeAuth
| auth::copilot::ExternalCopilotAuthSource::PiAuth
)
{
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "GitHub Copilot".to_string(),
source_name: source.display_name().to_string(),
path: source.path(),
action: ExternalAuthReviewAction::Copilot(source),
});
}
if let Some(source) = auth::cursor::has_unconsented_external_auth() {
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "Cursor".to_string(),
source_name: source.display_name().to_string(),
path: source.path()?,
action: ExternalAuthReviewAction::Cursor(source),
});
}
Ok(candidates)
}
pub fn parse_external_auth_review_selection(input: &str, count: usize) -> Result<Vec<usize>> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
if matches!(trimmed.to_ascii_lowercase().as_str(), "a" | "all") {
return Ok((0..count).collect());
}
let mut selected = Vec::new();
for part in trimmed.split(',') {
let value = part.trim();
if value.is_empty() {
continue;
}
let index: usize = value.parse().map_err(|_| {
anyhow::anyhow!(
"Invalid selection '{}'. Enter numbers like 1,3 or 'a' for all.",
value
)
})?;
if index == 0 || index > count {
anyhow::bail!(
"Selection '{}' is out of range. Enter 1-{} or 'a' for all.",
index,
count
);
}
let zero_based = index - 1;
if !selected.contains(&zero_based) {
selected.push(zero_based);
}
}
Ok(selected)
}
fn prompt_to_review_external_auth_sources(
candidates: &[ExternalAuthReviewCandidate],
) -> Result<Vec<usize>> {
if candidates.is_empty() {
return Ok(Vec::new());
}
eprintln!();
eprintln!("Found existing logins that jcode can reuse.");
eprintln!("Nothing has been imported yet.");
eprintln!(
"Approve the sources you want jcode to read in place; rejected sources stay untouched."
);
eprintln!();
for (index, candidate) in candidates.iter().enumerate() {
eprintln!(
" {}. {:<22} via {}",
index + 1,
candidate.provider_summary,
candidate.source_name
);
eprintln!(" {}", candidate.path.display());
}
eprintln!();
eprint!("Approve sources [a=all, Enter=skip, example: 1,3]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
parse_external_auth_review_selection(&input, candidates.len())
}
fn approve_external_auth_review_candidate(candidate: &ExternalAuthReviewCandidate) -> Result<()> {
match candidate.action {
ExternalAuthReviewAction::SharedExternal(source) => {
auth::external::trust_external_auth_source(source)?
}
ExternalAuthReviewAction::CodexLegacy => auth::codex::trust_legacy_auth_for_future_use()?,
ExternalAuthReviewAction::ClaudeCode => auth::claude::trust_external_auth_source(
auth::claude::ExternalClaudeAuthSource::ClaudeCode,
)?,
ExternalAuthReviewAction::ClaudeCodeNative => {
// Trust, then snapshot the Keychain/env credentials into jcode's own
// auth.json so future loads and refreshes do not depend on the
// (possibly prompting) Keychain. A successful trust with a failed
// copy still leaves the env-token path usable.
auth::claude::trust_native_source()?;
if let Err(err) = auth::claude::import_native_credentials_into_account() {
crate::logging::warn(&format!(
"Trusted Claude Code native credentials but could not snapshot them into jcode: {err}"
));
}
}
ExternalAuthReviewAction::GeminiCli => auth::gemini::trust_cli_auth_for_future_use()?,
ExternalAuthReviewAction::Copilot(source) => {
auth::copilot::trust_external_auth_source(source)?
}
ExternalAuthReviewAction::Cursor(source) => {
auth::cursor::trust_external_auth_source(source)?
}
}
Ok(())
}
fn revoke_external_auth_review_candidate(candidate: &ExternalAuthReviewCandidate) -> Result<()> {
match candidate.action {
ExternalAuthReviewAction::SharedExternal(source) => {
crate::config::Config::revoke_external_auth_source_for_path(
source.source_id(),
&candidate.path,
)?
}
ExternalAuthReviewAction::CodexLegacy => {
crate::config::Config::revoke_external_auth_source_for_path(
auth::codex::LEGACY_CODEX_AUTH_SOURCE_ID,
&candidate.path,
)?
}
ExternalAuthReviewAction::ClaudeCode => {
crate::config::Config::revoke_external_auth_source_for_path(
auth::claude::CLAUDE_CODE_AUTH_SOURCE_ID,
&candidate.path,
)?
}
ExternalAuthReviewAction::ClaudeCodeNative => {
crate::config::Config::revoke_external_auth_source(
auth::claude::CLAUDE_CODE_NATIVE_AUTH_SOURCE_ID,
)?
}
ExternalAuthReviewAction::GeminiCli => {
crate::config::Config::revoke_external_auth_source_for_path(
auth::gemini::GEMINI_CLI_AUTH_SOURCE_ID,
&candidate.path,
)?
}
ExternalAuthReviewAction::Copilot(source) => {
crate::config::Config::revoke_external_auth_source_for_path(
source.source_id(),
&candidate.path,
)?
}
ExternalAuthReviewAction::Cursor(source) => {
crate::config::Config::revoke_external_auth_source_for_path(
source.source_id(),
&candidate.path,
)?
}
}
Ok(())
}
/// Render a human-friendly note about how soon a token-based credential
/// expires, used to tell the user when a re-login is likely needed without
/// failing the import.
fn token_freshness_note(expires_at_ms: i64) -> String {
let now_ms = chrono::Utc::now().timestamp_millis();
if expires_at_ms <= now_ms {
" The access token is expired; jcode will refresh it on first use, or run /login if that fails.".to_string()
} else {
String::new()
}
}
// Import validation is intentionally *non-destructive*: it only checks that
// reusable credentials are present after trusting the source. It must NOT
// perform a live OAuth refresh, because OAuth refresh tokens are single-use --
// refreshing here would rotate (and thus burn) the source's refresh token and
// then discard the rotated result, breaking both jcode and the original tool.
// Expired tokens are still imported: they get refreshed lazily (and persisted)
// at request time, or the user is prompted to /login.
async fn validate_claude_import() -> Result<String> {
let creds = auth::claude::load_credentials()?;
if creds.access_token.trim().is_empty() && creds.refresh_token.trim().is_empty() {
anyhow::bail!("Claude source did not expose a usable access or refresh token.");
}
Ok(format!(
"Loaded Claude credentials.{}",
token_freshness_note(creds.expires_at)
))
}
async fn validate_openai_import() -> Result<String> {
let creds = auth::codex::load_credentials()?;
if creds.refresh_token.trim().is_empty() {
if creds.access_token.trim().is_empty() {
anyhow::bail!("OpenAI source did not expose a usable token or API key.");
}
return Ok("Loaded OpenAI API key credentials.".to_string());
}
Ok(format!(
"Loaded OpenAI OAuth credentials.{}",
creds
.expires_at
.map(token_freshness_note)
.unwrap_or_default()
))
}
async fn validate_gemini_import() -> Result<String> {
let tokens = auth::gemini::load_tokens()?;
Ok(format!(
"Loaded Gemini credentials.{}",
token_freshness_note(tokens.expires_at)
))
}
async fn validate_antigravity_import() -> Result<String> {
let tokens = auth::antigravity::load_tokens()?;
Ok(format!(
"Loaded Antigravity credentials.{}",
token_freshness_note(tokens.expires_at)
))
}
async fn validate_copilot_import() -> Result<String> {
// Presence check only: confirm a GitHub token is readable. The
// GitHub->Copilot exchange happens lazily at request time.
let _github_token = auth::copilot::load_github_token()?;
Ok("Loaded GitHub Copilot credentials.".to_string())
}
async fn validate_cursor_import() -> Result<String> {
let has_api_key = auth::cursor::has_cursor_api_key();
let has_vscdb = auth::cursor::has_cursor_vscdb_token();
if has_api_key || has_vscdb {
Ok(format!(
"Cursor native source loaded (api_key={}, vscdb_token={}).",
has_api_key, has_vscdb
))
} else {
anyhow::bail!("Cursor source did not expose a usable auth token.")
}
}
fn validate_openrouter_like_import() -> Result<String> {
for (env_key, env_file) in crate::provider_catalog::openrouter_like_api_key_sources() {
if crate::provider_catalog::load_api_key_from_env_or_config(&env_key, &env_file).is_some() {
return Ok(format!("Loaded API key for `{}`.", env_key));
}
}
anyhow::bail!("No reusable API key became available after import.")
}
async fn validate_shared_external_import(
source: auth::external::ExternalAuthSource,
) -> Result<String> {
let mut errors = Vec::new();
for label in auth::external::source_provider_labels(source) {
let result = match label {
"OpenAI/Codex" => validate_openai_import().await,
"Claude" => validate_claude_import().await,
"Gemini" => validate_gemini_import().await,
"Antigravity" => validate_antigravity_import().await,
"GitHub Copilot" => validate_copilot_import().await,
"OpenRouter/API-key providers" => validate_openrouter_like_import(),
_ => continue,
};
match result {
Ok(detail) => return Ok(detail),
Err(err) => errors.push(format!("{}: {}", label, err)),
}
}
anyhow::bail!(errors.join("; "))
}
async fn validate_external_auth_review_candidate(
candidate: &ExternalAuthReviewCandidate,
) -> Result<String> {
match candidate.action {
ExternalAuthReviewAction::SharedExternal(source) => {
validate_shared_external_import(source).await
}
ExternalAuthReviewAction::CodexLegacy => validate_openai_import().await,
ExternalAuthReviewAction::ClaudeCode => validate_claude_import().await,
ExternalAuthReviewAction::ClaudeCodeNative => validate_claude_import().await,
ExternalAuthReviewAction::GeminiCli => validate_gemini_import().await,
ExternalAuthReviewAction::Copilot(_) => validate_copilot_import().await,
ExternalAuthReviewAction::Cursor(_) => validate_cursor_import().await,
}
}
pub async fn maybe_run_external_auth_auto_import_flow() -> Result<Option<usize>> {
if !can_prompt_for_external_auth() {
return Ok(None);
}
let candidates = pending_external_auth_review_candidates()?;
if candidates.is_empty() {
return Ok(None);
}
let selected = prompt_to_review_external_auth_sources(&candidates)?;
let outcome = run_external_auth_auto_import_candidates(&candidates, &selected).await?;
for line in &outcome.messages {
eprintln!("{}", line);
}
auth::AuthStatus::invalidate_cache();
Ok(Some(outcome.imported))
}
pub fn format_external_auth_review_candidates_markdown(
candidates: &[ExternalAuthReviewCandidate],
) -> String {
let mut message = String::from(
"**Auto Import Existing Logins**\n\nFound existing logins that jcode can reuse. Nothing has been imported yet.\n\nReply with `a` to approve all, `1,3` to approve specific sources, or `/cancel` to abort.\n",
);
for (index, candidate) in candidates.iter().enumerate() {
message.push_str(&format!(
"\n{}. **{}** via {}\n - `{}`\n",
index + 1,
candidate.provider_summary,
candidate.source_name,
candidate.path.display()
));
}
message
}
pub async fn run_external_auth_auto_import_candidates(
candidates: &[ExternalAuthReviewCandidate],
selected: &[usize],
) -> Result<ExternalAuthAutoImportOutcome> {
let mut outcome = ExternalAuthAutoImportOutcome {
imported: 0,
messages: Vec::new(),
imported_auth_labels: Vec::new(),
};
for &index in selected {
let Some(candidate) = candidates.get(index) else {
continue;
};
approve_external_auth_review_candidate(candidate)?;
match validate_external_auth_review_candidate(candidate).await {
Ok(detail) => {
outcome.imported += 1;
outcome
.imported_auth_labels
.extend(candidate.telemetry_auth_labels());
outcome.messages.push(format!(
"{} (from {}): {}",
candidate.provider_summary, candidate.source_name, detail
));
}
Err(err) => {
let _ = revoke_external_auth_review_candidate(candidate);
outcome.messages.push(format!(
"{} (from {}): {}",
candidate.provider_summary, candidate.source_name, err
));
}
}
}
auth::AuthStatus::invalidate_cache();
Ok(outcome)
}
#[cfg(test)]
mod render_markdown_tests {
use super::ExternalAuthAutoImportOutcome;
#[test]
fn empty_outcome_reports_nothing_imported() {
let outcome = ExternalAuthAutoImportOutcome {
imported: 0,
messages: Vec::new(),
imported_auth_labels: Vec::new(),
};
assert_eq!(
outcome.render_markdown(),
"No external logins were imported."
);
}
#[test]
fn groups_imported_and_skipped_with_counts() {
let outcome = ExternalAuthAutoImportOutcome {
imported: 2,
messages: vec![
"✓ OpenAI/Codex (from Codex auth.json): Loaded OpenAI OAuth credentials."
.to_string(),
"✓ Claude (from Claude Code): Loaded Claude credentials.".to_string(),
"✕ Cursor (from Cursor native): no usable auth token.".to_string(),
],
imported_auth_labels: vec![("openai", "import"), ("claude", "import")],
};
let md = outcome.render_markdown();
assert!(md.starts_with("**Logins imported**"), "got: {md}");
assert!(md.contains("Reusing 2 existing logins:"), "got: {md}");
assert!(
md.contains("- OpenAI/Codex (from Codex auth.json): Loaded OpenAI OAuth credentials."),
"got: {md}"
);
assert!(md.contains("Skipped 1 source:"), "got: {md}");
assert!(
md.contains("- Cursor (from Cursor native): no usable auth token."),
"got: {md}"
);
// Markers themselves should be stripped from the rendered list.
assert!(!md.contains('✓'), "got: {md}");
assert!(!md.contains('✕'), "got: {md}");
}
#[test]
fn singular_wording_for_one_login() {
let outcome = ExternalAuthAutoImportOutcome {
imported: 1,
messages: vec!["✓ Gemini (from Gemini CLI): Loaded Gemini credentials.".to_string()],
imported_auth_labels: vec![("gemini", "import")],
};
let md = outcome.render_markdown();
assert!(md.contains("Reusing 1 existing login:"), "got: {md}");
}
#[test]
fn fixture_candidate_reports_import_auth_labels() {
use super::ExternalAuthReviewCandidate;
// The fixture points at the legacy Codex action -> OpenAI provider.
let candidate = ExternalAuthReviewCandidate::fixture("OpenAI/Codex", "Codex auth.json");
assert_eq!(
candidate.telemetry_auth_labels(),
vec![("openai", "import")]
);
}
}
+66
View File
@@ -0,0 +1,66 @@
#![allow(
unknown_lints,
clippy::collapsible_match,
clippy::manual_checked_ops,
clippy::unnecessary_sort_by,
clippy::useless_conversion
)]
// The `swarm` tool's `json!` parameter schema is large; the default macro
// recursion limit (128) is exceeded once more properties are added.
#![recursion_limit = "256"]
//! Application core for jcode (upper layer).
//!
//! This crate holds the server/tool/agent layer and its presentation-adjacent
//! leaves. The foundational layer it builds on (provider, auth, config, session,
//! message, memory, telemetry, ...) lives in the `jcode-base` crate and is
//! re-exported here via `pub use jcode_base::*`, so every existing
//! `crate::<module>` path (e.g. `crate::config`, `crate::provider`) keeps
//! resolving unchanged across this crate and the root `jcode` crate, which in
//! turn re-exports this crate via `pub use jcode_app_core::*`.
// Foundational layer: re-export every `jcode-base` module so `crate::<module>`
// paths resolve here exactly as they did before the split.
pub use jcode_base::*;
// Upper layer (server / tool / agent and supporting leaves).
pub mod agent;
pub mod ambient;
pub mod ambient_runner;
pub mod ambient_scheduler;
pub mod build;
pub mod catchup;
pub mod channel;
pub mod external_auth;
pub mod mission;
pub mod network_retry;
pub mod notifications;
pub mod overnight;
pub mod perf;
pub mod replay;
pub mod restart_snapshot;
pub mod server;
pub mod server_spawn;
pub mod session_effort;
pub mod session_launch;
pub mod session_rebuild;
pub mod setup_hints;
pub mod ssh_remote;
pub mod startup_profile;
pub mod tool;
pub mod turn_cancel_registry;
pub mod update;
use std::sync::Mutex;
static CURRENT_SESSION_ID: Mutex<Option<String>> = Mutex::new(None);
pub fn set_current_session(session_id: &str) {
if let Ok(mut guard) = CURRENT_SESSION_ID.lock() {
*guard = Some(session_id.to_string());
}
}
pub fn get_current_session() -> Option<String> {
CURRENT_SESSION_ID.lock().ok()?.clone()
}

Some files were not shown because too many files have changed in this diff Show More