Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb971e9aa | ||
|
|
a2ad08fb52 | ||
|
|
b9ca80e801 | ||
|
|
5f249ba6ca | ||
|
|
d331b0b2a3 | ||
|
|
09867915ef | ||
|
|
edce333620 | ||
|
|
d1c29a576c | ||
|
|
17d4ba08f9 | ||
|
|
901203c2b2 | ||
|
|
d8609267a1 | ||
|
|
a7b8438881 | ||
|
|
584d0fb77e | ||
|
|
4512df79e6 | ||
|
|
91f14112bf | ||
|
|
fc7113ba3c | ||
|
|
00a5e318bb | ||
|
|
017a570d7d | ||
|
|
63d26d4a3e | ||
|
|
5f7416f3ef | ||
|
|
fb3fe89d33 | ||
|
|
aa9a5dbe72 | ||
|
|
7a60cd42ac | ||
|
|
97c934a647 | ||
|
|
cadc46f5dc | ||
|
|
b957e12370 | ||
|
|
ff92a00fea | ||
|
|
f7a4cb64ab |
No files matched your search
@@ -1,375 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
# PRD F8, Gitea edition. On a v* tag push, build the single-file kigi binary
|
|
||||||
# for the five supported targets, package kigi-<version>-<target-triple>.
|
|
||||||
# {tar.gz|zip} (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), and publish
|
|
||||||
# them as a Gitea Release alongside SHA256SUMS. install.sh, install.ps1 and
|
|
||||||
# auto_update::release_asset_name() all resolve these exact names — keep the
|
|
||||||
# naming in lockstep with them.
|
|
||||||
#
|
|
||||||
# RUNNERS. Gitea has no hosted runners; every label below must be a runner you
|
|
||||||
# registered yourself (docs/RELEASE.md has the register commands):
|
|
||||||
#
|
|
||||||
# ubuntu-latest Linux x86_64, container mode — the label the Dokploy box's
|
|
||||||
# runner already advertises. Builds both Linux targets;
|
|
||||||
# aarch64 is a cross-compile, so this leg installs the
|
|
||||||
# aarch64 GNU toolchain. Because the image is a bare
|
|
||||||
# runner-image, the Linux legs provision their own apt
|
|
||||||
# packages and rustup (see the provision steps below).
|
|
||||||
# Registering a dedicated `linux-x86_64:docker://...` runner
|
|
||||||
# instead is fine — swap the three labels back if you do.
|
|
||||||
# macos-arm64 Apple Silicon Mac, host mode. Builds both Darwin targets;
|
|
||||||
# x86_64 is a cross-compile against the same SDK. macOS
|
|
||||||
# CANNOT be built anywhere else — Apple's SDK licence
|
|
||||||
# restricts it to Apple hardware.
|
|
||||||
# windows-x86_64 Windows x86_64, host mode, with VS 2022 Build Tools
|
|
||||||
# (C++ workload). Needed because the tree links ring,
|
|
||||||
# mimalloc, zstd-sys and libz-sys against a static MSVC CRT
|
|
||||||
# (-C target-feature=+crt-static).
|
|
||||||
#
|
|
||||||
# SHELLS. Every step names its shell explicitly and the Windows leg never uses
|
|
||||||
# bash. On a normal Windows box `bash` resolves to WSL (not Git Bash), and
|
|
||||||
# `pwsh` (PowerShell 7) is usually absent — so Windows steps use `powershell`,
|
|
||||||
# which is the built-in 5.1. Keep those steps 5.1-compatible: no `&&`, no
|
|
||||||
# ternary, no null-coalescing.
|
|
||||||
#
|
|
||||||
# Note: because this file exists, Gitea ignores .github/workflows entirely.
|
|
||||||
#
|
|
||||||
# PUBLISH ORDER. The release is created as a draft and only made public once
|
|
||||||
# SHA256SUMS is attached, so `releases/latest` never hands the installer a
|
|
||||||
# release whose checksum manifest is still missing.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ["v*"]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
# Matches the version pinned by the dotslash launcher in bin/protoc. That
|
|
||||||
# launcher is not used here: it has no Windows entry and needs a `cargo
|
|
||||||
# install dotslash` first, so every platform fetches protoc directly.
|
|
||||||
PROTOC_VERSION: "29.3"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prepare:
|
|
||||||
name: prepare draft release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
release_id: ${{ steps.draft.outputs.release_id }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Check tag matches workspace version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
tag_version="${GITHUB_REF_NAME#v}"
|
|
||||||
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
|
|
||||||
if [ "$tag_version" != "$cargo_version" ]; then
|
|
||||||
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create or reuse draft release
|
|
||||||
id: draft
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# The runner-image is bare; jq and curl are both used below.
|
|
||||||
if ! command -v jq >/dev/null || ! command -v curl >/dev/null; then
|
|
||||||
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
$SUDO apt-get update -qq
|
|
||||||
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Idempotent so a re-run of a failed tag build reuses the same draft
|
|
||||||
# instead of erroring on a duplicate tag_name.
|
|
||||||
existing="$(curl -sS -H "Authorization: token $TOKEN" \
|
|
||||||
"$API/releases/tags/$GITHUB_REF_NAME")"
|
|
||||||
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
|
|
||||||
|
|
||||||
if [ -z "$release_id" ]; then
|
|
||||||
prerelease=false
|
|
||||||
case "$GITHUB_REF_NAME" in *-*) prerelease=true ;; esac
|
|
||||||
created="$(curl -fsS -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\",\"draft\":true,\"prerelease\":$prerelease}" \
|
|
||||||
"$API/releases")"
|
|
||||||
release_id="$(printf '%s' "$created" | jq -r '.id')"
|
|
||||||
echo "Created draft release $release_id (prerelease=$prerelease)"
|
|
||||||
else
|
|
||||||
echo "Reusing release $release_id for $GITHUB_REF_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: build (${{ matrix.target }})
|
|
||||||
needs: prepare
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- target: x86_64-unknown-linux-gnu
|
|
||||||
runner: ubuntu-latest
|
|
||||||
protoc: linux-x86_64
|
|
||||||
ext: tar.gz
|
|
||||||
linux: true
|
|
||||||
- target: aarch64-unknown-linux-gnu
|
|
||||||
runner: ubuntu-latest
|
|
||||||
protoc: linux-x86_64
|
|
||||||
ext: tar.gz
|
|
||||||
linux: true
|
|
||||||
cross_gnu: true
|
|
||||||
- target: aarch64-apple-darwin
|
|
||||||
runner: macos-arm64
|
|
||||||
protoc: osx-aarch_64
|
|
||||||
ext: tar.gz
|
|
||||||
- target: x86_64-apple-darwin
|
|
||||||
runner: macos-arm64
|
|
||||||
protoc: osx-aarch_64
|
|
||||||
ext: tar.gz
|
|
||||||
- target: x86_64-pc-windows-msvc
|
|
||||||
runner: windows-x86_64
|
|
||||||
protoc: win64
|
|
||||||
ext: zip
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# ── linux container provisioning ──────────────────────────────────────
|
|
||||||
# The Linux legs run in a bare gitea runner-image, which ships git and
|
|
||||||
# node for the JavaScript actions but no compiler, no unzip (protoc) and
|
|
||||||
# no rustup. Host-mode macOS and Windows already have their toolchains
|
|
||||||
# installed, so this step is Linux-only.
|
|
||||||
- name: Provision Linux build deps
|
|
||||||
if: matrix.linux
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
$SUDO apt-get update -qq
|
|
||||||
$SUDO apt-get install -y -qq --no-install-recommends \
|
|
||||||
build-essential pkg-config ca-certificates curl unzip tar jq
|
|
||||||
|
|
||||||
# ── protoc ────────────────────────────────────────────────────────────
|
|
||||||
- name: Install protoc (unix)
|
|
||||||
if: matrix.ext == 'tar.gz'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${{ matrix.protoc }}.zip"
|
|
||||||
curl -fsSL -o protoc.zip "$url"
|
|
||||||
rm -rf "$HOME/protoc"
|
|
||||||
unzip -q -o protoc.zip -d "$HOME/protoc"
|
|
||||||
chmod +x "$HOME/protoc/bin/protoc"
|
|
||||||
echo "$HOME/protoc/bin" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Install protoc (windows)
|
|
||||||
if: matrix.ext == 'zip'
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v$env:PROTOC_VERSION/protoc-$env:PROTOC_VERSION-${{ matrix.protoc }}.zip"
|
|
||||||
Invoke-WebRequest -Uri $url -OutFile protoc.zip
|
|
||||||
$dest = Join-Path $env:USERPROFILE "protoc"
|
|
||||||
if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
|
|
||||||
Expand-Archive protoc.zip -DestinationPath $dest -Force
|
|
||||||
Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $dest "bin")
|
|
||||||
|
|
||||||
# ── toolchain ─────────────────────────────────────────────────────────
|
|
||||||
# rust-toolchain.toml pins the channel (1.97.0) and preinstalls both
|
|
||||||
# Linux targets; `rustup show` materialises exactly that.
|
|
||||||
- name: Install toolchain (unix)
|
|
||||||
if: matrix.ext == 'tar.gz'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# No-op on the macOS host runner, which has rustup installed already.
|
|
||||||
# `--default-toolchain none` leaves the channel choice to
|
|
||||||
# rust-toolchain.toml, which `rustup show` then materialises.
|
|
||||||
if ! command -v rustup >/dev/null; then
|
|
||||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
|
|
||||||
| sh -s -- -y --no-modify-path --profile minimal --default-toolchain none
|
|
||||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
|
||||||
fi
|
|
||||||
rustup show
|
|
||||||
rustup target add ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Install toolchain (windows)
|
|
||||||
if: matrix.ext == 'zip'
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
rustup show
|
|
||||||
rustup target add ${{ matrix.target }}
|
|
||||||
|
|
||||||
# ring, mimalloc, zstd-sys and libz-sys are all cc-rs builds, so the
|
|
||||||
# cross target needs its own C toolchain — cc-rs picks these up from the
|
|
||||||
# target-suffixed vars.
|
|
||||||
- name: Install aarch64 cross toolchain
|
|
||||||
if: matrix.cross_gnu
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
|
|
||||||
$SUDO apt-get update -qq
|
|
||||||
$SUDO apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
|
|
||||||
{
|
|
||||||
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc"
|
|
||||||
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc"
|
|
||||||
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++"
|
|
||||||
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar"
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
# ── build ─────────────────────────────────────────────────────────────
|
|
||||||
- name: Build kigi (unix)
|
|
||||||
if: matrix.ext == 'tar.gz'
|
|
||||||
shell: bash
|
|
||||||
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Build kigi (windows)
|
|
||||||
if: matrix.ext == 'zip'
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
|
|
||||||
# ── package, upload, checksum ─────────────────────────────────────────
|
|
||||||
# Archives go straight to the release rather than through the artifact
|
|
||||||
# store: they are large, and the release is the only place install.sh
|
|
||||||
# can read them from anyway. Only the checksum line travels as an
|
|
||||||
# artifact, for the publish job to concatenate.
|
|
||||||
- name: Package and upload (unix)
|
|
||||||
if: matrix.ext == 'tar.gz'
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
version="${GITHUB_REF_NAME#v}"
|
|
||||||
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
|
|
||||||
staging="$(mktemp -d)"
|
|
||||||
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
|
|
||||||
cp LICENSE "$staging/"
|
|
||||||
[ -f NOTICE ] && cp NOTICE "$staging/"
|
|
||||||
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
|
|
||||||
[ -f "$f" ] && cp "$f" "$staging/"
|
|
||||||
done
|
|
||||||
tar -C "$staging" -czf "$archive" .
|
|
||||||
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@${archive}" \
|
|
||||||
"$API/releases/$RELEASE_ID/assets?name=${archive}" > /dev/null
|
|
||||||
echo "Uploaded $archive"
|
|
||||||
|
|
||||||
if command -v sha256sum >/dev/null; then
|
|
||||||
sha256sum "$archive" > "checksum-${{ matrix.target }}.txt"
|
|
||||||
else
|
|
||||||
shasum -a 256 "$archive" > "checksum-${{ matrix.target }}.txt"
|
|
||||||
fi
|
|
||||||
cat "checksum-${{ matrix.target }}.txt"
|
|
||||||
|
|
||||||
- name: Package and upload (windows)
|
|
||||||
if: matrix.ext == 'zip'
|
|
||||||
shell: powershell
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$version = $env:GITHUB_REF_NAME.TrimStart("v")
|
|
||||||
$archive = "kigi-$version-${{ matrix.target }}.zip"
|
|
||||||
$staging = Join-Path $env:RUNNER_TEMP "staging"
|
|
||||||
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
|
|
||||||
New-Item -ItemType Directory -Force -Path $staging | Out-Null
|
|
||||||
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
|
|
||||||
Copy-Item LICENSE $staging
|
|
||||||
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
|
|
||||||
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
|
|
||||||
if (Test-Path $f) { Copy-Item $f $staging }
|
|
||||||
}
|
|
||||||
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive -Force
|
|
||||||
|
|
||||||
# System32\curl.exe, not PowerShell's `curl` alias for
|
|
||||||
# Invoke-WebRequest, which cannot do multipart uploads.
|
|
||||||
$api = "$env:API/releases/$env:RELEASE_ID/assets?name=$archive"
|
|
||||||
& "$env:SystemRoot\System32\curl.exe" -fsS -X POST `
|
|
||||||
-H "Authorization: token $env:TOKEN" `
|
|
||||||
-F "attachment=@$archive" $api
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "asset upload failed ($LASTEXITCODE)" }
|
|
||||||
Write-Host "Uploaded $archive"
|
|
||||||
|
|
||||||
# sha256sum's exact format, so the publish job can just concatenate.
|
|
||||||
# ASCII keeps the BOM out — a BOM would corrupt the first hash.
|
|
||||||
$hash = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant()
|
|
||||||
"$hash $archive" | Out-File -FilePath "checksum-${{ matrix.target }}.txt" -Encoding ascii
|
|
||||||
Get-Content "checksum-${{ matrix.target }}.txt"
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: checksum-${{ matrix.target }}
|
|
||||||
path: checksum-${{ matrix.target }}.txt
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: publish release
|
|
||||||
needs: [prepare, build]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
path: checksums
|
|
||||||
|
|
||||||
- name: Assemble SHA256SUMS
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# download-artifact v3 nests each artifact in its own directory. The
|
|
||||||
# Windows line arrives CRLF-terminated; strip CR so every line is LF
|
|
||||||
# for install.sh's `read -r hash name`.
|
|
||||||
find checksums -name 'checksum-*.txt' -exec cat {} + \
|
|
||||||
| tr -d '\r' | grep -v '^$' | sort -k2 > SHA256SUMS
|
|
||||||
lines="$(wc -l < SHA256SUMS)"
|
|
||||||
if [ "$lines" -ne 5 ]; then
|
|
||||||
echo "expected 5 checksum lines, got $lines" >&2
|
|
||||||
cat SHA256SUMS >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cat SHA256SUMS
|
|
||||||
|
|
||||||
- name: Attach SHA256SUMS and publish
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@SHA256SUMS" \
|
|
||||||
"$API/releases/$RELEASE_ID/assets?name=SHA256SUMS" > /dev/null
|
|
||||||
|
|
||||||
# Flip the draft public only now that every asset install.sh looks
|
|
||||||
# for is attached.
|
|
||||||
curl -fsS -X PATCH \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"draft":false}' \
|
|
||||||
"$API/releases/$RELEASE_ID" > /dev/null
|
|
||||||
echo "Published $GITHUB_REF_NAME"
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# PRD F8: on tag push v*, build the single-file kigi binary for the five
|
||||||
|
# supported targets, package per-target archives named
|
||||||
|
# kigi-<version>-<target-triple>.{tar.gz|zip} (binary + LICENSE + NOTICE +
|
||||||
|
# THIRD-PARTY-NOTICES), generate SHA256SUMS, and publish everything as a
|
||||||
|
# GitHub Release. install.sh / install.ps1 and the in-app self-updater
|
||||||
|
# (kigi-update) both resolve these exact asset names — keep the naming in
|
||||||
|
# lockstep with auto_update::release_asset_name().
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: build (${{ matrix.target }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
os: macos-14
|
||||||
|
# macos-14 runners are arm64; the x86_64 slice is a cross-compile
|
||||||
|
# against the same SDK (macos-13 Intel runners are deprecated).
|
||||||
|
- target: x86_64-apple-darwin
|
||||||
|
os: macos-14
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
os: ubuntu-24.04
|
||||||
|
- target: aarch64-unknown-linux-gnu
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
- target: x86_64-pc-windows-msvc
|
||||||
|
os: windows-2022
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check tag matches workspace version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
tag_version="${GITHUB_REF_NAME#v}"
|
||||||
|
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
|
||||||
|
if [ "$tag_version" != "$cargo_version" ]; then
|
||||||
|
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install toolchain (rust-toolchain.toml)
|
||||||
|
run: rustup show
|
||||||
|
|
||||||
|
- name: Add build target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Install dotslash (protoc launcher)
|
||||||
|
run: cargo install dotslash --locked
|
||||||
|
|
||||||
|
# Windows cannot exec the dotslash launcher directly (no shebang
|
||||||
|
# support → os error 193), and the build script's documented fallback
|
||||||
|
# is a protoc on PATH — install the same version the dotslash file
|
||||||
|
# pins (v29.3).
|
||||||
|
- name: Install protoc (Windows PATH fallback)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile protoc.zip
|
||||||
|
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
|
||||||
|
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
key: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Build kigi (release-dist)
|
||||||
|
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Package archive (tar.gz)
|
||||||
|
if: runner.os != 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
|
||||||
|
staging="$(mktemp -d)"
|
||||||
|
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
|
||||||
|
cp LICENSE "$staging/"
|
||||||
|
[ -f NOTICE ] && cp NOTICE "$staging/"
|
||||||
|
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
|
||||||
|
[ -f "$f" ] && cp "$f" "$staging/"
|
||||||
|
done
|
||||||
|
tar -C "$staging" -czf "$archive" .
|
||||||
|
shasum -a 256 "$archive" || sha256sum "$archive"
|
||||||
|
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Package archive (zip)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$version = $env:GITHUB_REF_NAME.TrimStart("v")
|
||||||
|
$archive = "kigi-$version-${{ matrix.target }}.zip"
|
||||||
|
$staging = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP "staging")
|
||||||
|
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
|
||||||
|
Copy-Item LICENSE $staging
|
||||||
|
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
|
||||||
|
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
|
||||||
|
if (Test-Path $f) { Copy-Item $f $staging }
|
||||||
|
}
|
||||||
|
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive
|
||||||
|
Get-FileHash -Algorithm SHA256 $archive
|
||||||
|
Add-Content -Path $env:GITHUB_ENV -Value "ARCHIVE=$archive"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.ARCHIVE }}
|
||||||
|
path: ${{ env.ARCHIVE }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: publish GitHub Release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
working-directory: dist
|
||||||
|
run: |
|
||||||
|
ls -l
|
||||||
|
sha256sum kigi-* > SHA256SUMS
|
||||||
|
cat SHA256SUMS
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: dist/*
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
|
generate_release_notes: true
|
||||||
|
fail_on_unmatched_files: true
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
name: Warm build cache
|
||||||
|
|
||||||
|
# Release builds run on TAG refs, and GitHub Actions cache isolation only
|
||||||
|
# lets a run restore caches created on its OWN ref or the DEFAULT branch.
|
||||||
|
# No workflow ran on `main`, so every release compiled the whole workspace
|
||||||
|
# cold on all five targets (~50 min wall clock, gated by Windows). This
|
||||||
|
# workflow builds the same `release-dist` profile on `main` so tag builds
|
||||||
|
# restore a warm default-branch cache.
|
||||||
|
#
|
||||||
|
# Triggers: dependency-affecting pushes to main (each release's version-bump
|
||||||
|
# commit warms the cache for the NEXT release), a weekly refresh so the
|
||||||
|
# cache never hits GitHub's 7-day unused-eviction, and manual dispatch.
|
||||||
|
#
|
||||||
|
# The setup steps mirror release.yml's build job (toolchain, target,
|
||||||
|
# dotslash/protoc, rust-cache key) — keep them in lockstep, or the cache
|
||||||
|
# key won't match and releases go back to cold builds.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
paths:
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "rust-toolchain.toml"
|
||||||
|
- ".github/workflows/warm-cache.yml"
|
||||||
|
schedule:
|
||||||
|
- cron: "17 5 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: warm-cache
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
warm:
|
||||||
|
name: warm (${{ matrix.target }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
os: macos-14
|
||||||
|
- target: x86_64-apple-darwin
|
||||||
|
os: macos-14
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
os: ubuntu-24.04
|
||||||
|
- target: aarch64-unknown-linux-gnu
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
- target: x86_64-pc-windows-msvc
|
||||||
|
os: windows-2022
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install toolchain (rust-toolchain.toml)
|
||||||
|
run: rustup show
|
||||||
|
|
||||||
|
- name: Add build target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Install dotslash (protoc launcher)
|
||||||
|
run: cargo install dotslash --locked
|
||||||
|
|
||||||
|
- name: Install protoc (Windows PATH fallback)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile protoc.zip
|
||||||
|
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
|
||||||
|
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
|
||||||
|
|
||||||
|
# Same key as release.yml so tag builds restore this cache verbatim.
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
key: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Build kigi (release-dist)
|
||||||
|
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
|
||||||
@@ -17,11 +17,19 @@ import) or any `KIMI_*` env var.
|
|||||||
Upstream sync record (update on every sync): fork baseline is upstream
|
Upstream sync record (update on every sync): fork baseline is upstream
|
||||||
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
|
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
|
||||||
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
|
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
|
||||||
the 0.1.13 cycle. Upstream is daily "Synced from monorepo" snapshots —
|
the 0.1.13 cycle and through `3794978` (2026-09-09, ≈v1.0.24) in the
|
||||||
|
0.1.15 cycle. Upstream is daily "Synced from monorepo" snapshots —
|
||||||
the commit BODIES carry per-change bullet lists, and
|
the commit BODIES carry per-change bullet lists, and
|
||||||
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
|
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
|
||||||
start any future sync from those two, not from raw diffs.
|
start any future sync from those two, not from raw diffs.
|
||||||
|
|
||||||
|
Known limitations left by the 0.1.15 sync (fix before relying on them):
|
||||||
|
the Windows frame writer writes through `std::io::stderr`, so a console
|
||||||
|
that stops reading still blocks exit-path prints behind std's stderr lock
|
||||||
|
(unix silences fd 2 after a timed-out writer join); the bwrap command
|
||||||
|
builder re-checks `Path::exists("/data")` and can omit the devbox bind on
|
||||||
|
a stat error that the profile classification treated as present.
|
||||||
|
|
||||||
## Hard constraints
|
## Hard constraints
|
||||||
|
|
||||||
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
|
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
|
||||||
|
|||||||
Generated
+67
-64
@@ -1626,9 +1626,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.10.1"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
@@ -5442,7 +5442,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-acp-lib"
|
name = "kigi-acp-lib"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent"
|
name = "kigi-agent"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent-lifecycle"
|
name = "kigi-agent-lifecycle"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-auth"
|
name = "kigi-auth"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-bin"
|
name = "kigi-bin"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-chat-state"
|
name = "kigi-chat-state"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"kigi-compaction",
|
"kigi-compaction",
|
||||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-codebase-graph"
|
name = "kigi-codebase-graph"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-compaction"
|
name = "kigi-compaction"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config"
|
name = "kigi-config"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config-types"
|
name = "kigi-config-types"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-crash-handler"
|
name = "kigi-crash-handler"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-env"
|
name = "kigi-env"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fast-worktree"
|
name = "kigi-fast-worktree"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-file-utils"
|
name = "kigi-file-utils"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fsnotify"
|
name = "kigi-fsnotify"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"criterion",
|
"criterion",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-gix-status"
|
name = "kigi-gix-status"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gix",
|
"gix",
|
||||||
"kigi-test-utils",
|
"kigi-test-utils",
|
||||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks"
|
name = "kigi-hooks"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks-plugins-types"
|
name = "kigi-hooks-plugins-types"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-http"
|
name = "kigi-http"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-auth",
|
"kigi-auth",
|
||||||
"kigi-log",
|
"kigi-log",
|
||||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hunk-tracker"
|
name = "kigi-hunk-tracker"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-interjection-core"
|
name = "kigi-interjection-core"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-log"
|
name = "kigi-log"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown"
|
name = "kigi-markdown"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"anstyle-lossy",
|
"anstyle-lossy",
|
||||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown-core"
|
name = "kigi-markdown-core"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mcp"
|
name = "kigi-mcp"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-memory"
|
name = "kigi-memory"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mermaid"
|
name = "kigi-mermaid"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"image",
|
"image",
|
||||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-models"
|
name = "kigi-models"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-minimal"
|
name = "kigi-pager-minimal"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-pty-harness"
|
name = "kigi-pager-pty-harness"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-render"
|
name = "kigi-pager-render"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-paths"
|
name = "kigi-paths"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"camino",
|
"camino",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-prompt-queue"
|
name = "kigi-prompt-queue"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-proto-build"
|
name = "kigi-proto-build"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pbjson-build",
|
"pbjson-build",
|
||||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-inline"
|
name = "kigi-ratatui-inline"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansi-width",
|
"ansi-width",
|
||||||
"anstyle-parse 0.2.7",
|
"anstyle-parse 0.2.7",
|
||||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-textarea"
|
name = "kigi-ratatui-textarea"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampler"
|
name = "kigi-sampler"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-openai",
|
"async-openai",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -6141,6 +6141,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
|
"kigi-secrets",
|
||||||
"kigi-test-support",
|
"kigi-test-support",
|
||||||
"kigi-version",
|
"kigi-version",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -6154,7 +6155,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampling-types"
|
name = "kigi-sampling-types"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert_matches",
|
"assert_matches",
|
||||||
"async-openai",
|
"async-openai",
|
||||||
@@ -6171,7 +6172,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sandbox"
|
name = "kigi-sandbox"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6186,13 +6187,14 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
|
"tempfile",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-secrets"
|
name = "kigi-secrets"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6230,7 +6232,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell"
|
name = "kigi-shell"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6293,6 +6295,7 @@ dependencies = [
|
|||||||
"kigi-sampler",
|
"kigi-sampler",
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-sandbox",
|
"kigi-sandbox",
|
||||||
|
"kigi-secrets",
|
||||||
"kigi-shared",
|
"kigi-shared",
|
||||||
"kigi-shell-base",
|
"kigi-shell-base",
|
||||||
"kigi-sqlite-journal",
|
"kigi-sqlite-journal",
|
||||||
@@ -6367,7 +6370,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell-base"
|
name = "kigi-shell-base"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6392,7 +6395,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sqlite-journal"
|
name = "kigi-sqlite-journal"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -6403,7 +6406,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-subagent-resolution"
|
name = "kigi-subagent-resolution"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
@@ -6418,7 +6421,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-system-power"
|
name = "kigi-system-power"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -6426,7 +6429,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-support"
|
name = "kigi-test-support"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6448,7 +6451,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-utils"
|
name = "kigi-test-utils"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"runfiles",
|
"runfiles",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6457,11 +6460,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-token-estimation"
|
name = "kigi-token-estimation"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-protocol"
|
name = "kigi-tool-protocol"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6472,7 +6475,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-runtime"
|
name = "kigi-tool-runtime"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6490,7 +6493,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-types"
|
name = "kigi-tool-types"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
@@ -6500,7 +6503,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools"
|
name = "kigi-tools"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -6577,7 +6580,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools-api"
|
name = "kigi-tools-api"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-proto-build",
|
"kigi-proto-build",
|
||||||
"kigi-tool-protocol",
|
"kigi-tool-protocol",
|
||||||
@@ -6590,11 +6593,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tracing-macros"
|
name = "kigi-tracing-macros"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tty-utils"
|
name = "kigi-tty-utils"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
@@ -6604,7 +6607,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tui"
|
name = "kigi-tui"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"ansi-to-tui",
|
"ansi-to-tui",
|
||||||
@@ -6691,7 +6694,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-update"
|
name = "kigi-update"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -6720,14 +6723,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-version"
|
name = "kigi-version"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace"
|
name = "kigi-workspace"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6806,7 +6809,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace-types"
|
name = "kigi-workspace-types"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -8840,7 +8843,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl"
|
name = "ptyctl"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -8858,7 +8861,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl-cli"
|
name = "ptyctl-cli"
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.13"
|
version = "0.1.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
Kigi is a community build; report vulnerabilities on the
|
Kigi is a community build; report vulnerabilities on the
|
||||||
[Kigi issue tracker](https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues).
|
[Kigi issue tracker](https://github.com/ZacharyZhang-NY/Kigi-CLI/issues).
|
||||||
Issues there are public, so please leave out any detail that would make a
|
Issues there are public, so please leave out any detail that would make a
|
||||||
report directly exploitable — open with the affected area and impact, and a
|
report directly exploitable — open with the affected area and impact, and a
|
||||||
maintainer will follow up for the rest.
|
maintainer will follow up for the rest.
|
||||||
|
|||||||
+309
-308
@@ -17,7 +17,7 @@ cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md
|
|||||||
- ISC License (27 crates)
|
- ISC License (27 crates)
|
||||||
- BSD 3-Clause "New" or "Revised" License (19 crates)
|
- BSD 3-Clause "New" or "Revised" License (19 crates)
|
||||||
- Unicode License v3 (19 crates)
|
- Unicode License v3 (19 crates)
|
||||||
- Mozilla Public License 2.0 (8 crates)
|
- Mozilla Public License 2.0 (9 crates)
|
||||||
- Do What The F*ck You Want To Public License (4 crates)
|
- Do What The F*ck You Want To Public License (4 crates)
|
||||||
- zlib License (4 crates)
|
- zlib License (4 crates)
|
||||||
- Boost Software License 1.0 (2 crates)
|
- Boost Software License 1.0 (2 crates)
|
||||||
@@ -3972,6 +3972,218 @@ Applies to:
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
|
- [dagre_rust 0.0.5](https://github.com/r3alst/dagre-rust)
|
||||||
|
- [graphlib_rust 0.0.2](https://github.com/r3alst/graphlib-rust)
|
||||||
|
- [ordered_hashmap 0.0.3](https://github.com/r3alst/ordered-hashmap)
|
||||||
|
|
||||||
|
```
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2023 Ameer Hamza
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apache License 2.0
|
||||||
|
|
||||||
|
Applies to:
|
||||||
|
|
||||||
- [zerocopy-derive 0.8.54](https://github.com/google/zerocopy)
|
- [zerocopy-derive 0.8.54](https://github.com/google/zerocopy)
|
||||||
- [zerocopy 0.8.54](https://github.com/google/zerocopy)
|
- [zerocopy 0.8.54](https://github.com/google/zerocopy)
|
||||||
|
|
||||||
@@ -6543,218 +6755,6 @@ Applies to:
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [dagre_rust 0.0.5](https://github.com/r3alst/dagre-rust)
|
|
||||||
- [graphlib_rust 0.0.2](https://github.com/r3alst/graphlib-rust)
|
|
||||||
- [ordered_hashmap 0.0.3](https://github.com/r3alst/ordered-hashmap)
|
|
||||||
|
|
||||||
```
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2023 Ameer Hamza
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Apache License 2.0
|
|
||||||
|
|
||||||
Applies to:
|
|
||||||
|
|
||||||
- [cassowary 0.3.0](https://github.com/dylanede/cassowary-rs)
|
- [cassowary 0.3.0](https://github.com/dylanede/cassowary-rs)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -13758,7 +13758,7 @@ Applies to:
|
|||||||
- [block-buffer 0.12.1](https://github.com/RustCrypto/utils)
|
- [block-buffer 0.12.1](https://github.com/RustCrypto/utils)
|
||||||
- [block-padding 0.4.2](https://github.com/RustCrypto/utils)
|
- [block-padding 0.4.2](https://github.com/RustCrypto/utils)
|
||||||
- [cbc 0.2.1](https://github.com/RustCrypto/block-modes)
|
- [cbc 0.2.1](https://github.com/RustCrypto/block-modes)
|
||||||
- [chacha20 0.10.1](https://github.com/RustCrypto/stream-ciphers)
|
- [chacha20 0.10.2](https://github.com/RustCrypto/stream-ciphers)
|
||||||
- [cipher 0.5.2](https://github.com/RustCrypto/traits)
|
- [cipher 0.5.2](https://github.com/RustCrypto/traits)
|
||||||
- [cmpv2 0.2.0](https://github.com/RustCrypto/formats/tree/master/cmpv2)
|
- [cmpv2 0.2.0](https://github.com/RustCrypto/formats/tree/master/cmpv2)
|
||||||
- [const-oid 0.10.2](https://github.com/RustCrypto/formats)
|
- [const-oid 0.10.2](https://github.com/RustCrypto/formats)
|
||||||
@@ -16436,68 +16436,68 @@ limitations under the License.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [kigi-proto-build 0.1.12](https://crates.io/crates/kigi-proto-build)
|
- [kigi-proto-build 0.1.15](https://crates.io/crates/kigi-proto-build)
|
||||||
- [kigi-acp-lib 0.1.12](https://crates.io/crates/kigi-acp-lib)
|
- [kigi-acp-lib 0.1.15](https://crates.io/crates/kigi-acp-lib)
|
||||||
- [kigi-agent 0.1.12](https://crates.io/crates/kigi-agent)
|
- [kigi-agent 0.1.15](https://crates.io/crates/kigi-agent)
|
||||||
- [kigi-agent-lifecycle 0.1.12](https://crates.io/crates/kigi-agent-lifecycle)
|
- [kigi-agent-lifecycle 0.1.15](https://crates.io/crates/kigi-agent-lifecycle)
|
||||||
- [kigi-auth 0.1.12](https://crates.io/crates/kigi-auth)
|
- [kigi-auth 0.1.15](https://crates.io/crates/kigi-auth)
|
||||||
- [kigi-bin 0.1.12](https://crates.io/crates/kigi-bin)
|
- [kigi-bin 0.1.15](https://crates.io/crates/kigi-bin)
|
||||||
- [kigi-chat-state 0.1.12](https://crates.io/crates/kigi-chat-state)
|
- [kigi-chat-state 0.1.15](https://crates.io/crates/kigi-chat-state)
|
||||||
- [kigi-codebase-graph 0.1.12](https://crates.io/crates/kigi-codebase-graph)
|
- [kigi-codebase-graph 0.1.15](https://crates.io/crates/kigi-codebase-graph)
|
||||||
- [kigi-config 0.1.12](https://crates.io/crates/kigi-config)
|
- [kigi-config 0.1.15](https://crates.io/crates/kigi-config)
|
||||||
- [kigi-config-types 0.1.12](https://crates.io/crates/kigi-config-types)
|
- [kigi-config-types 0.1.15](https://crates.io/crates/kigi-config-types)
|
||||||
- [kigi-crash-handler 0.1.12](https://crates.io/crates/kigi-crash-handler)
|
- [kigi-crash-handler 0.1.15](https://crates.io/crates/kigi-crash-handler)
|
||||||
- [kigi-env 0.1.12](https://crates.io/crates/kigi-env)
|
- [kigi-env 0.1.15](https://crates.io/crates/kigi-env)
|
||||||
- [kigi-fast-worktree 0.1.12](https://crates.io/crates/kigi-fast-worktree)
|
- [kigi-fast-worktree 0.1.15](https://crates.io/crates/kigi-fast-worktree)
|
||||||
- [kigi-file-utils 0.1.12](https://crates.io/crates/kigi-file-utils)
|
- [kigi-file-utils 0.1.15](https://crates.io/crates/kigi-file-utils)
|
||||||
- [kigi-fsnotify 0.1.12](https://crates.io/crates/kigi-fsnotify)
|
- [kigi-fsnotify 0.1.15](https://crates.io/crates/kigi-fsnotify)
|
||||||
- [kigi-gix-status 0.1.12](https://crates.io/crates/kigi-gix-status)
|
- [kigi-gix-status 0.1.15](https://crates.io/crates/kigi-gix-status)
|
||||||
- [kigi-hooks 0.1.12](https://crates.io/crates/kigi-hooks)
|
- [kigi-hooks 0.1.15](https://crates.io/crates/kigi-hooks)
|
||||||
- [kigi-hooks-plugins-types 0.1.12](https://crates.io/crates/kigi-hooks-plugins-types)
|
- [kigi-hooks-plugins-types 0.1.15](https://crates.io/crates/kigi-hooks-plugins-types)
|
||||||
- [kigi-http 0.1.12](https://crates.io/crates/kigi-http)
|
- [kigi-http 0.1.15](https://crates.io/crates/kigi-http)
|
||||||
- [kigi-hunk-tracker 0.1.12](https://crates.io/crates/kigi-hunk-tracker)
|
- [kigi-hunk-tracker 0.1.15](https://crates.io/crates/kigi-hunk-tracker)
|
||||||
- [kigi-log 0.1.12](https://crates.io/crates/kigi-log)
|
- [kigi-log 0.1.15](https://crates.io/crates/kigi-log)
|
||||||
- [kigi-markdown 0.1.12](https://crates.io/crates/kigi-markdown)
|
- [kigi-markdown 0.1.15](https://crates.io/crates/kigi-markdown)
|
||||||
- [kigi-markdown-core 0.1.12](https://crates.io/crates/kigi-markdown-core)
|
- [kigi-markdown-core 0.1.15](https://crates.io/crates/kigi-markdown-core)
|
||||||
- [kigi-mcp 0.1.12](https://crates.io/crates/kigi-mcp)
|
- [kigi-mcp 0.1.15](https://crates.io/crates/kigi-mcp)
|
||||||
- [kigi-memory 0.1.12](https://crates.io/crates/kigi-memory)
|
- [kigi-memory 0.1.15](https://crates.io/crates/kigi-memory)
|
||||||
- [kigi-models 0.1.12](https://crates.io/crates/kigi-models)
|
- [kigi-models 0.1.15](https://crates.io/crates/kigi-models)
|
||||||
- [kigi-pager-minimal 0.1.12](https://crates.io/crates/kigi-pager-minimal)
|
- [kigi-pager-minimal 0.1.15](https://crates.io/crates/kigi-pager-minimal)
|
||||||
- [kigi-pager-pty-harness 0.1.12](https://crates.io/crates/kigi-pager-pty-harness)
|
- [kigi-pager-pty-harness 0.1.15](https://crates.io/crates/kigi-pager-pty-harness)
|
||||||
- [kigi-pager-render 0.1.12](https://crates.io/crates/kigi-pager-render)
|
- [kigi-pager-render 0.1.15](https://crates.io/crates/kigi-pager-render)
|
||||||
- [kigi-paths 0.1.12](https://crates.io/crates/kigi-paths)
|
- [kigi-paths 0.1.15](https://crates.io/crates/kigi-paths)
|
||||||
- [kigi-prompt-queue 0.1.12](https://crates.io/crates/kigi-prompt-queue)
|
- [kigi-prompt-queue 0.1.15](https://crates.io/crates/kigi-prompt-queue)
|
||||||
- [kigi-ratatui-inline 0.1.12](https://crates.io/crates/kigi-ratatui-inline)
|
- [kigi-ratatui-inline 0.1.15](https://crates.io/crates/kigi-ratatui-inline)
|
||||||
- [kigi-ratatui-textarea 0.1.12](https://crates.io/crates/kigi-ratatui-textarea)
|
- [kigi-ratatui-textarea 0.1.15](https://crates.io/crates/kigi-ratatui-textarea)
|
||||||
- [kigi-sampler 0.1.12](https://crates.io/crates/kigi-sampler)
|
- [kigi-sampler 0.1.15](https://crates.io/crates/kigi-sampler)
|
||||||
- [kigi-sampling-types 0.1.12](https://crates.io/crates/kigi-sampling-types)
|
- [kigi-sampling-types 0.1.15](https://crates.io/crates/kigi-sampling-types)
|
||||||
- [kigi-sandbox 0.1.12](https://crates.io/crates/kigi-sandbox)
|
- [kigi-sandbox 0.1.15](https://crates.io/crates/kigi-sandbox)
|
||||||
- [kigi-secrets 0.1.12](https://crates.io/crates/kigi-secrets)
|
- [kigi-secrets 0.1.15](https://crates.io/crates/kigi-secrets)
|
||||||
- [kigi-shared 0.0.0](https://crates.io/crates/kigi-shared)
|
- [kigi-shared 0.0.0](https://crates.io/crates/kigi-shared)
|
||||||
- [kigi-shell 0.1.12](https://crates.io/crates/kigi-shell)
|
- [kigi-shell 0.1.15](https://crates.io/crates/kigi-shell)
|
||||||
- [kigi-shell-base 0.1.12](https://crates.io/crates/kigi-shell-base)
|
- [kigi-shell-base 0.1.15](https://crates.io/crates/kigi-shell-base)
|
||||||
- [kigi-sqlite-journal 0.1.12](https://crates.io/crates/kigi-sqlite-journal)
|
- [kigi-sqlite-journal 0.1.15](https://crates.io/crates/kigi-sqlite-journal)
|
||||||
- [kigi-subagent-resolution 0.1.12](https://crates.io/crates/kigi-subagent-resolution)
|
- [kigi-subagent-resolution 0.1.15](https://crates.io/crates/kigi-subagent-resolution)
|
||||||
- [kigi-system-power 0.1.12](https://crates.io/crates/kigi-system-power)
|
- [kigi-system-power 0.1.15](https://crates.io/crates/kigi-system-power)
|
||||||
- [kigi-test-support 0.1.12](https://crates.io/crates/kigi-test-support)
|
- [kigi-test-support 0.1.15](https://crates.io/crates/kigi-test-support)
|
||||||
- [kigi-token-estimation 0.1.12](https://crates.io/crates/kigi-token-estimation)
|
- [kigi-token-estimation 0.1.15](https://crates.io/crates/kigi-token-estimation)
|
||||||
- [kigi-tools 0.1.12](https://crates.io/crates/kigi-tools)
|
- [kigi-tools 0.1.15](https://crates.io/crates/kigi-tools)
|
||||||
- [kigi-tools-api 0.1.12](https://crates.io/crates/kigi-tools-api)
|
- [kigi-tools-api 0.1.15](https://crates.io/crates/kigi-tools-api)
|
||||||
- [kigi-tracing-macros 0.1.12](https://crates.io/crates/kigi-tracing-macros)
|
- [kigi-tracing-macros 0.1.15](https://crates.io/crates/kigi-tracing-macros)
|
||||||
- [kigi-tty-utils 0.1.12](https://crates.io/crates/kigi-tty-utils)
|
- [kigi-tty-utils 0.1.15](https://crates.io/crates/kigi-tty-utils)
|
||||||
- [kigi-tui 0.1.12](https://crates.io/crates/kigi-tui)
|
- [kigi-tui 0.1.15](https://crates.io/crates/kigi-tui)
|
||||||
- [kigi-update 0.1.12](https://crates.io/crates/kigi-update)
|
- [kigi-update 0.1.15](https://crates.io/crates/kigi-update)
|
||||||
- [kigi-version 0.1.12](https://crates.io/crates/kigi-version)
|
- [kigi-version 0.1.15](https://crates.io/crates/kigi-version)
|
||||||
- [kigi-workspace 0.1.12](https://crates.io/crates/kigi-workspace)
|
- [kigi-workspace 0.1.15](https://crates.io/crates/kigi-workspace)
|
||||||
- [kigi-workspace-types 0.1.12](https://crates.io/crates/kigi-workspace-types)
|
- [kigi-workspace-types 0.1.15](https://crates.io/crates/kigi-workspace-types)
|
||||||
- [ptyctl 0.1.12](https://crates.io/crates/ptyctl)
|
- [ptyctl 0.1.15](https://crates.io/crates/ptyctl)
|
||||||
- [ptyctl-cli 0.1.12](https://crates.io/crates/ptyctl-cli)
|
- [ptyctl-cli 0.1.15](https://crates.io/crates/ptyctl-cli)
|
||||||
- [kigi-compaction 0.1.12](https://crates.io/crates/kigi-compaction)
|
- [kigi-compaction 0.1.15](https://crates.io/crates/kigi-compaction)
|
||||||
- [kigi-interjection-core 0.1.12](https://crates.io/crates/kigi-interjection-core)
|
- [kigi-interjection-core 0.1.15](https://crates.io/crates/kigi-interjection-core)
|
||||||
- [kigi-test-utils 0.1.12](https://crates.io/crates/kigi-test-utils)
|
- [kigi-test-utils 0.1.15](https://crates.io/crates/kigi-test-utils)
|
||||||
- [kigi-tool-protocol 0.1.12](https://crates.io/crates/kigi-tool-protocol)
|
- [kigi-tool-protocol 0.1.15](https://crates.io/crates/kigi-tool-protocol)
|
||||||
- [kigi-tool-runtime 0.1.12](https://crates.io/crates/kigi-tool-runtime)
|
- [kigi-tool-runtime 0.1.15](https://crates.io/crates/kigi-tool-runtime)
|
||||||
- [kigi-tool-types 0.1.12](https://crates.io/crates/kigi-tool-types)
|
- [kigi-tool-types 0.1.15](https://crates.io/crates/kigi-tool-types)
|
||||||
- [agent-client-protocol 0.10.4](https://github.com/agentclientprotocol/rust-sdk)
|
- [agent-client-protocol 0.10.4](https://github.com/agentclientprotocol/rust-sdk)
|
||||||
- [allocator-api2 0.2.21](https://github.com/zakarumych/allocator-api2)
|
- [allocator-api2 0.2.21](https://github.com/zakarumych/allocator-api2)
|
||||||
- [anes 0.1.6](https://github.com/zrzka/anes-rs)
|
- [anes 0.1.6](https://github.com/zrzka/anes-rs)
|
||||||
@@ -16772,7 +16772,7 @@ limitations under the License.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [kigi-mermaid 0.1.12](https://crates.io/crates/kigi-mermaid)
|
- [kigi-mermaid 0.1.15](https://crates.io/crates/kigi-mermaid)
|
||||||
|
|
||||||
```
|
```
|
||||||
Roboto-Regular.ttf
|
Roboto-Regular.ttf
|
||||||
@@ -23473,6 +23473,37 @@ SOFTWARE.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
|
- [mermaid-to-svg 0.1.0](https://github.com/warpdotdev/mermaid-to-svg)
|
||||||
|
|
||||||
|
```
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2025-2026 Denver Technologies, Inc.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### MIT License
|
||||||
|
|
||||||
|
Applies to:
|
||||||
|
|
||||||
- [rustybuzz 0.20.1](https://github.com/harfbuzz/rustybuzz)
|
- [rustybuzz 0.20.1](https://github.com/harfbuzz/rustybuzz)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -23746,37 +23777,6 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [mermaid-to-svg 0.1.0](https://github.com/warpdotdev/mermaid-to-svg)
|
|
||||||
|
|
||||||
```
|
|
||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2025-2026 Denver Technologies, Inc.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### MIT License
|
|
||||||
|
|
||||||
Applies to:
|
|
||||||
|
|
||||||
- [urlencoding 2.1.3](https://github.com/kornelski/rust_urlencoding)
|
- [urlencoding 2.1.3](https://github.com/kornelski/rust_urlencoding)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -23834,6 +23834,8 @@ SOFTWARE.
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
|
- [nucleo-matcher 0.3.1](https://github.com/helix-editor/nucleo)
|
||||||
|
- [nucleo 0.5.0](https://github.com/helix-editor/nucleo)
|
||||||
- [nucleo 0.5.0](https://github.com/helix-editor/nucleo)
|
- [nucleo 0.5.0](https://github.com/helix-editor/nucleo)
|
||||||
- [dtoa-short 0.3.5](https://github.com/upsuper/dtoa-short)
|
- [dtoa-short 0.3.5](https://github.com/upsuper/dtoa-short)
|
||||||
|
|
||||||
@@ -24603,7 +24605,6 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice
|
|||||||
|
|
||||||
Applies to:
|
Applies to:
|
||||||
|
|
||||||
- [nucleo-matcher 0.3.1](https://github.com/helix-editor/nucleo)
|
|
||||||
- [option-ext 0.2.0](https://github.com/soc/option-ext.git)
|
- [option-ext 0.2.0](https://github.com/soc/option-ext.git)
|
||||||
- [selectors 0.26.0](https://github.com/servo/stylo)
|
- [selectors 0.26.0](https://github.com/servo/stylo)
|
||||||
|
|
||||||
|
|||||||
@@ -252,11 +252,20 @@ pub fn by_name(name: &str) -> Option<AgentDefinition> {
|
|||||||
by_name_with_home(name, dirs::home_dir().as_deref(), kigi.as_deref())
|
by_name_with_home(name, dirs::home_dir().as_deref(), kigi.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An agent name is one file stem: a separator or `..` would resolve a file outside the searched directory with that directory's trust scope.
|
||||||
|
fn is_single_path_component(name: &str) -> bool {
|
||||||
|
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\'])
|
||||||
|
}
|
||||||
|
|
||||||
fn by_name_with_home(
|
fn by_name_with_home(
|
||||||
name: &str,
|
name: &str,
|
||||||
home: Option<&Path>,
|
home: Option<&Path>,
|
||||||
kigi_home: Option<&Path>,
|
kigi_home: Option<&Path>,
|
||||||
) -> Option<AgentDefinition> {
|
) -> Option<AgentDefinition> {
|
||||||
|
if !is_single_path_component(name) {
|
||||||
|
tracing::warn!(name, "rejected agent name: not a single path component");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
// Check built-ins first — type-safe via BuiltinAgentName strum enum.
|
// Check built-ins first — type-safe via BuiltinAgentName strum enum.
|
||||||
// Legacy pre-rebrand agent types (persisted in old session files) are
|
// Legacy pre-rebrand agent types (persisted in old session files) are
|
||||||
// mapped onto their current names first; see `canonical_agent_type`.
|
// mapped onto their current names first; see `canonical_agent_type`.
|
||||||
@@ -298,6 +307,10 @@ fn by_name_in_cwd_with_home(
|
|||||||
home: Option<&Path>,
|
home: Option<&Path>,
|
||||||
kigi_home: Option<&Path>,
|
kigi_home: Option<&Path>,
|
||||||
) -> Option<AgentDefinition> {
|
) -> Option<AgentDefinition> {
|
||||||
|
if !is_single_path_component(name) {
|
||||||
|
tracing::warn!(name, "rejected agent name: not a single path component");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if let Some(def) = load_project_definition_by_name(name, cwd) {
|
if let Some(def) = load_project_definition_by_name(name, cwd) {
|
||||||
return Some(def);
|
return Some(def);
|
||||||
}
|
}
|
||||||
@@ -827,6 +840,30 @@ mod tests {
|
|||||||
assert!(def.is_none());
|
assert!(def.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `../outside` must not load a file beyond the agents directory under that directory's scope.
|
||||||
|
#[test]
|
||||||
|
fn by_name_rejects_names_that_leave_the_agents_directory() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project = tmp.path().join("project");
|
||||||
|
let agents_dir = project.join(".kigi").join("agents");
|
||||||
|
fs::create_dir_all(&agents_dir).unwrap();
|
||||||
|
write_agent_file(
|
||||||
|
&project,
|
||||||
|
"outside.md",
|
||||||
|
"outside",
|
||||||
|
"Lives outside the agents dir",
|
||||||
|
);
|
||||||
|
write_agent_file(&agents_dir, "inside.md", "inside", "Lives inside");
|
||||||
|
assert!(by_name_in_cwd_with_home("../../outside", &project, None, None).is_none());
|
||||||
|
assert!(by_name_in_cwd_with_home("..", &project, None, None).is_none());
|
||||||
|
assert!(by_name_in_cwd_with_home("a/b", &project, None, None).is_none());
|
||||||
|
assert!(by_name_with_home("../../outside", None, None).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
by_name_in_cwd_with_home("inside", &project, None, None).map(|d| d.name),
|
||||||
|
Some("inside".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_discover_finds_md_files() {
|
fn test_discover_finds_md_files() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -146,27 +146,33 @@ async fn run_setup_command(json: bool) {
|
|||||||
.expect("setup report has no non-serializable values");
|
.expect("setup report has no non-serializable values");
|
||||||
println!("{out}");
|
println!("{out}");
|
||||||
if !report.configured {
|
if !report.configured {
|
||||||
eprintln!(
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
|
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Couldn't fetch managed configuration. {e}");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(
|
||||||
|
"Couldn't fetch managed configuration. {e}"
|
||||||
|
));
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match managed_config::run_setup().await {
|
match managed_config::run_setup().await {
|
||||||
SetupOutcome::Installed => eprintln!("Applied managed configuration."),
|
SetupOutcome::Installed => {
|
||||||
|
kigi_tui::best_effort_stderr::eprint_line("Applied managed configuration.")
|
||||||
|
}
|
||||||
SetupOutcome::NothingConfigured => {
|
SetupOutcome::NothingConfigured => {
|
||||||
eprintln!(
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
|
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
SetupOutcome::Failed(e) => {
|
SetupOutcome::Failed(e) => {
|
||||||
eprintln!("Couldn't apply managed configuration. {e}");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(
|
||||||
|
"Couldn't apply managed configuration. {e}"
|
||||||
|
));
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,8 +214,8 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
|
|||||||
println!("{info:#?}");
|
println!("{info:#?}");
|
||||||
} else {
|
} else {
|
||||||
print_leader_descriptor(&descriptor);
|
print_leader_descriptor(&descriptor);
|
||||||
eprintln!(
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
" (detailed info unavailable — leader does not advertise control capabilities)"
|
" (detailed info unavailable — leader does not advertise control capabilities)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
client.cancel();
|
client.cancel();
|
||||||
@@ -220,7 +226,7 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
|
|||||||
async fn kill_leaders() -> Result<()> {
|
async fn kill_leaders() -> Result<()> {
|
||||||
let leaders = kigi_shell::leader::discover_leaders().await;
|
let leaders = kigi_shell::leader::discover_leaders().await;
|
||||||
if leaders.is_empty() {
|
if leaders.is_empty() {
|
||||||
eprintln!("No leader candidates found.");
|
kigi_tui::best_effort_stderr::eprint_line("No leader candidates found.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut killed = 0u32;
|
let mut killed = 0u32;
|
||||||
@@ -231,7 +237,9 @@ async fn kill_leaders() -> Result<()> {
|
|||||||
};
|
};
|
||||||
if !kigi_shell::util::is_kigi_process(pid) {
|
if !kigi_shell::util::is_kigi_process(pid) {
|
||||||
if let Some(ref lock) = d.lock_path {
|
if let Some(ref lock) = d.lock_path {
|
||||||
eprintln!(" PID {pid} is not a kigi process, removing stale lock");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(
|
||||||
|
" PID {pid} is not a kigi process, removing stale lock"
|
||||||
|
));
|
||||||
let _ = std::fs::remove_file(lock);
|
let _ = std::fs::remove_file(lock);
|
||||||
cleaned += 1;
|
cleaned += 1;
|
||||||
}
|
}
|
||||||
@@ -240,19 +248,23 @@ async fn kill_leaders() -> Result<()> {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
eprintln!(" Killing leader PID {pid}");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(" Killing leader PID {pid}"));
|
||||||
if let Err(e) = kigi_shell::util::kill_process_by_pid(pid) {
|
if let Err(e) = kigi_shell::util::kill_process_by_pid(pid) {
|
||||||
eprintln!(" warning: failed to terminate PID {pid}: {e}");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(
|
||||||
|
" warning: failed to terminate PID {pid}: {e}"
|
||||||
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
killed += 1;
|
killed += 1;
|
||||||
}
|
}
|
||||||
if killed > 0 {
|
if killed > 0 {
|
||||||
eprintln!("Killed {killed} leader process(es).");
|
kigi_tui::best_effort_stderr::eprint_line(&format!("Killed {killed} leader process(es)."));
|
||||||
} else if cleaned > 0 {
|
} else if cleaned > 0 {
|
||||||
eprintln!("No live leader processes found (cleaned up {cleaned} stale lock(s)).");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(
|
||||||
|
"No live leader processes found (cleaned up {cleaned} stale lock(s))."
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
eprintln!("No live leader processes found.");
|
kigi_tui::best_effort_stderr::eprint_line("No live leader processes found.");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -295,7 +307,7 @@ fn print_leader_descriptor(d: &LeaderDescriptor) {
|
|||||||
.map(|p| p.display().to_string())
|
.map(|p| p.display().to_string())
|
||||||
.unwrap_or_else(|| "?".into());
|
.unwrap_or_else(|| "?".into());
|
||||||
let state = format!("{:?}", d.classification);
|
let state = format!("{:?}", d.classification);
|
||||||
eprintln!(" PID {pid} ({state}) -- {sock}");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(" PID {pid} ({state}) -- {sock}"));
|
||||||
}
|
}
|
||||||
fn leader_descriptor_json(d: &LeaderDescriptor) -> serde_json::Value {
|
fn leader_descriptor_json(d: &LeaderDescriptor) -> serde_json::Value {
|
||||||
serde_json::json!(
|
serde_json::json!(
|
||||||
@@ -1233,10 +1245,12 @@ fn main() {
|
|||||||
// A host out of threads (RLIMIT_NPROC / pids.max) is an
|
// A host out of threads (RLIMIT_NPROC / pids.max) is an
|
||||||
// environment problem, not a bug — report it, don't panic.
|
// environment problem, not a bug — report it, don't panic.
|
||||||
kigi_tty_utils::restore_native_stderr();
|
kigi_tty_utils::restore_native_stderr();
|
||||||
eprintln!("Error: failed to start the async runtime: {e}");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(
|
||||||
eprintln!(
|
"Error: failed to start the async runtime: {e}"
|
||||||
|
));
|
||||||
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
"The host may be out of threads (RLIMIT_NPROC or the cgroup pids ceiling); \
|
"The host may be out of threads (RLIMIT_NPROC or the cgroup pids ceiling); \
|
||||||
close other processes or raise the limit, then retry."
|
close other processes or raise the limit, then retry.",
|
||||||
);
|
);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -1245,7 +1259,8 @@ fn main() {
|
|||||||
kigi_log::debug_log::flush();
|
kigi_log::debug_log::flush();
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
kigi_tty_utils::restore_native_stderr();
|
kigi_tty_utils::restore_native_stderr();
|
||||||
eprintln!("Error: {e:#}");
|
// fd 2 may be a closed pane: one attempt, never a panic under panic = "abort".
|
||||||
|
kigi_tui::best_effort_stderr::eprint_line(&format!("Error: {e:#}"));
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1596,16 +1611,25 @@ async fn async_main() -> Result<()> {
|
|||||||
let result = kigi_tui::app::run(args, bg_update_rx).await;
|
let result = kigi_tui::app::run(args, bg_update_rx).await;
|
||||||
kigi_sandbox::flush();
|
kigi_sandbox::flush();
|
||||||
match result {
|
match result {
|
||||||
Ok(true) => {
|
Ok(exit) if exit.quit_for_update => {
|
||||||
let adopted = bg_update_wait.lock().await.take();
|
let adopted = bg_update_wait.lock().await.take();
|
||||||
if finish_update_on_exit(adopted, &update_config).await {
|
let installed =
|
||||||
eprintln!("Update installed. Run `kigi` to start.");
|
finish_update_on_exit(adopted, &update_config, exit.terminal_reading).await;
|
||||||
} else {
|
// A terminal that stopped reading at teardown would block these prints.
|
||||||
eprintln!("Update did not complete. Run `kigi update` to retry.");
|
if exit.terminal_reading {
|
||||||
|
if installed {
|
||||||
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
|
"Update installed. Run `kigi` to start.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
|
"Update did not complete. Run `kigi update` to retry.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Ok(false) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1621,10 +1645,15 @@ async fn async_main() -> Result<()> {
|
|||||||
async fn finish_update_on_exit(
|
async fn finish_update_on_exit(
|
||||||
adopted: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
|
adopted: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
|
||||||
update_config: &UpdateConfig,
|
update_config: &UpdateConfig,
|
||||||
|
terminal_reading: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let run_blocking = |reason: Option<String>| async move {
|
let run_blocking = |reason: Option<String>| async move {
|
||||||
|
// The blocking updater prints and its child inherits stderr: not on a stalled terminal.
|
||||||
|
if !terminal_reading {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if let Some(reason) = reason {
|
if let Some(reason) = reason {
|
||||||
eprintln!("{reason}");
|
kigi_tui::best_effort_stderr::eprint_line(&reason);
|
||||||
}
|
}
|
||||||
auto_update::run_update_if_available(
|
auto_update::run_update_if_available(
|
||||||
auto_update::UpdateRunMode::Blocking,
|
auto_update::UpdateRunMode::Blocking,
|
||||||
@@ -1636,7 +1665,11 @@ async fn finish_update_on_exit(
|
|||||||
};
|
};
|
||||||
match adopted {
|
match adopted {
|
||||||
Some(handle) => {
|
Some(handle) => {
|
||||||
eprintln!("Waiting for the update download to finish...");
|
if terminal_reading {
|
||||||
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
|
"Waiting for the update download to finish...",
|
||||||
|
);
|
||||||
|
}
|
||||||
match handle.await {
|
match handle.await {
|
||||||
Ok(Ok(status)) if status.success() => true,
|
Ok(Ok(status)) if status.success() => true,
|
||||||
Ok(Ok(status)) => {
|
Ok(Ok(status)) => {
|
||||||
@@ -1809,7 +1842,9 @@ async fn signal_leaders_to_relaunch(installed_version: &str) {
|
|||||||
to_version,
|
to_version,
|
||||||
..
|
..
|
||||||
})) => {
|
})) => {
|
||||||
eprintln!(" ↻ Relaunching shared session (leader {from_version} → {to_version})…");
|
kigi_tui::best_effort_stderr::eprint_line(&format!(
|
||||||
|
" ↻ Relaunching shared session (leader {from_version} → {to_version})…"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(Ok(kigi_shell::leader::ControlPayload::RelaunchDeclined { reason })) => {
|
Ok(Ok(kigi_shell::leader::ControlPayload::RelaunchDeclined { reason })) => {
|
||||||
tracing::debug!(% reason, "Leader declined relaunch");
|
tracing::debug!(% reason, "Leader declined relaunch");
|
||||||
|
|||||||
@@ -390,14 +390,24 @@ impl ChatStateActor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the entire conversation, persist, re-estimate `total_tokens`,
|
/// Reseed after a rewrite: the raw estimate scaled by the provider-confirmed ratio, capped at the pre-rewrite total (see COMPACTION.md).
|
||||||
/// and emit reset + token-update events.
|
pub(super) fn reseed_total_tokens(&self, base_estimate: u64) -> u64 {
|
||||||
///
|
let pre_replace_total = self.state.total_tokens;
|
||||||
/// Compaction replaces carry the provider-side overhead forward as a
|
let mut estimated_tokens =
|
||||||
/// *ratio* (`base_estimate × provider_total ÷ estimate_at_last_response`,
|
if pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
|
||||||
/// capped at the pre-compaction total; `base_estimate` when that estimate is
|
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
|
||||||
/// 0) so the reseed neither springs back nor over-counts (see
|
(base_estimate as f64 * ratio).round() as u64
|
||||||
/// `COMPACTION.md`).
|
} else {
|
||||||
|
base_estimate
|
||||||
|
};
|
||||||
|
// Over-counting fires premature compaction; an under-count self-heals on the next response.
|
||||||
|
if pre_replace_total > 0 {
|
||||||
|
estimated_tokens = estimated_tokens.min(pre_replace_total);
|
||||||
|
}
|
||||||
|
estimated_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the conversation, persist, reseed `total_tokens` and emit reset + token events; `is_compaction` only marks the turn capture.
|
||||||
pub(super) fn replace_conversation(
|
pub(super) fn replace_conversation(
|
||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<ConversationItem>,
|
items: Vec<ConversationItem>,
|
||||||
@@ -407,28 +417,16 @@ impl ChatStateActor {
|
|||||||
if is_compaction && let Some(cap) = &mut self.state.turn_capture {
|
if is_compaction && let Some(cap) = &mut self.state.turn_capture {
|
||||||
cap.compaction_occurred = true;
|
cap.compaction_occurred = true;
|
||||||
}
|
}
|
||||||
let pre_replace_total = self.state.total_tokens;
|
|
||||||
// `harness_trace_buffer` / `harness_trace_turns` intentionally untouched:
|
// `harness_trace_buffer` / `harness_trace_turns` intentionally untouched:
|
||||||
// the planner/verifier subagents ran, so their sealed trace turns survive
|
// the planner/verifier subagents ran, so their sealed trace turns survive
|
||||||
// a conversation replace (same intent as the `TruncateToPromptIndex` arm).
|
// a conversation replace (same intent as the `TruncateToPromptIndex` arm).
|
||||||
self.persistence.replace_history(&items);
|
self.persistence.replace_history(&items);
|
||||||
let base_estimate = super::state::estimate_conversation_tokens(&items);
|
let base_estimate = super::state::estimate_conversation_tokens(&items);
|
||||||
let mut estimated_tokens =
|
let estimated_tokens = self.reseed_total_tokens(base_estimate);
|
||||||
if is_compaction && pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
|
|
||||||
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
|
|
||||||
(base_estimate as f64 * ratio).round() as u64
|
|
||||||
} else {
|
|
||||||
base_estimate
|
|
||||||
};
|
|
||||||
// Compaction must never appear to increase usage.
|
|
||||||
if is_compaction && pre_replace_total > 0 {
|
|
||||||
estimated_tokens = estimated_tokens.min(pre_replace_total);
|
|
||||||
}
|
|
||||||
self.state.conversation = items;
|
self.state.conversation = items;
|
||||||
self.state.estimated_tokens_since_model = 0;
|
self.state.estimated_tokens_since_model = 0;
|
||||||
self.state.total_tokens = estimated_tokens;
|
self.state.total_tokens = estimated_tokens;
|
||||||
self.state.estimate_at_last_response =
|
self.state.estimate_at_last_response = base_estimate;
|
||||||
super::state::estimate_conversation_tokens(&self.state.conversation);
|
|
||||||
self.rebase_turn_capture_offset();
|
self.rebase_turn_capture_offset();
|
||||||
self.send_event(ChatStateEvent::ConversationReset {
|
self.send_event(ChatStateEvent::ConversationReset {
|
||||||
new_len: self.state.conversation.len(),
|
new_len: self.state.conversation.len(),
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ impl ChatStateActor {
|
|||||||
self.state.conversation.truncate(truncate_at);
|
self.state.conversation.truncate(truncate_at);
|
||||||
self.state.prompt_texts.truncate(target_prompt_index);
|
self.state.prompt_texts.truncate(target_prompt_index);
|
||||||
self.state.prompt_index = target_prompt_index;
|
self.state.prompt_index = target_prompt_index;
|
||||||
self.state.total_tokens =
|
let base_estimate = super::state::estimate_conversation_tokens(&self.state.conversation);
|
||||||
super::state::estimate_conversation_tokens(&self.state.conversation);
|
self.state.total_tokens = self.reseed_total_tokens(base_estimate);
|
||||||
self.state.estimated_tokens_since_model = 0;
|
self.state.estimated_tokens_since_model = 0;
|
||||||
self.state.estimate_at_last_response = self.state.total_tokens;
|
self.state.estimate_at_last_response = base_estimate;
|
||||||
|
|
||||||
self.persistence.replace_history(&self.state.conversation);
|
self.persistence.replace_history(&self.state.conversation);
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,10 @@ pub fn estimate_item_tokens(item: &ConversationItem) -> u64 {
|
|||||||
kigi_token_estimation::estimate_tokens(&b.text_summary())
|
kigi_token_estimation::estimate_tokens(&b.text_summary())
|
||||||
}
|
}
|
||||||
ConversationItem::Reasoning(r) => {
|
ConversationItem::Reasoning(r) => {
|
||||||
// Summary + content text follow the standard bytes-per-token
|
// Text and ciphertext are the same reasoning twice: the larger wins (base64 is 4/3 over raw).
|
||||||
// estimate; encrypted blobs are base64 and don't survive
|
|
||||||
// tokenization 1:1, so estimate at len/4 as well.
|
|
||||||
let text_bytes = kigi_sampling_types::reasoning_item_text(r).len();
|
let text_bytes = kigi_sampling_types::reasoning_item_text(r).len();
|
||||||
let enc_bytes = r.encrypted_content.as_deref().map(str::len).unwrap_or(0);
|
let enc_bytes = r.encrypted_content.as_deref().map(str::len).unwrap_or(0);
|
||||||
((text_bytes + enc_bytes) as u64) / kigi_token_estimation::BYTES_PER_TOKEN
|
(text_bytes.max(enc_bytes * 3 / 4) as u64) / kigi_token_estimation::BYTES_PER_TOKEN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -658,22 +658,66 @@ async fn compaction_reseed_without_provider_count_matches_plain_estimate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_compaction_replace_does_not_carry_overhead() {
|
async fn non_compaction_replace_carries_confirmed_total() {
|
||||||
|
// A rewind/mode-switch replace scales from the provider-confirmed count, not the raw estimate.
|
||||||
let h = TestHarness::new();
|
let h = TestHarness::new();
|
||||||
h.handle
|
h.handle
|
||||||
.push_user_message(ConversationItem::user("x".repeat(4000)));
|
.push_user_message(ConversationItem::user("x".repeat(4000)));
|
||||||
h.handle.record_token_usage(51_000);
|
h.handle.record_token_usage(500);
|
||||||
|
// Estimate at last response = 1_000, confirmed = 500 → ratio 0.5.
|
||||||
|
|
||||||
h.handle
|
h.handle
|
||||||
.replace_conversation(vec![ConversationItem::user("q".repeat(4000))]);
|
.replace_conversation(vec![ConversationItem::user("q".repeat(4000))]);
|
||||||
|
|
||||||
let total = h.handle.get_total_tokens().await;
|
let total = h.handle.get_total_tokens().await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
total, 1_000,
|
total, 500,
|
||||||
"non-compaction replace (e.g. rewind) keeps the plain estimate"
|
"same-size replace carries the provider-confirmed count"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replace_never_increases_total_tokens() {
|
||||||
|
// A growing replace is capped at the confirmed total; the under-count self-heals on the next usage.
|
||||||
|
let h = TestHarness::new();
|
||||||
|
h.handle
|
||||||
|
.push_user_message(ConversationItem::user("x".repeat(4000)));
|
||||||
|
h.handle.record_token_usage(1_500);
|
||||||
|
|
||||||
|
h.handle
|
||||||
|
.replace_conversation(vec![ConversationItem::user("q".repeat(40_000))]);
|
||||||
|
|
||||||
|
assert_eq!(h.handle.get_total_tokens().await, 1_500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn truncate_scales_from_confirmed_total() {
|
||||||
|
// Rewind path: TruncateToPromptIndex must use the same carry as replace_conversation.
|
||||||
|
let h = TestHarness::new();
|
||||||
|
h.handle
|
||||||
|
.push_user_message(ConversationItem::user("x".repeat(4000)));
|
||||||
|
h.handle.increment_prompt_index();
|
||||||
|
h.handle
|
||||||
|
.push_user_message(ConversationItem::user("y".repeat(4000)));
|
||||||
|
h.handle.increment_prompt_index();
|
||||||
|
h.handle.record_token_usage(1_000);
|
||||||
|
// Estimate at last response = 2_000, confirmed = 1_000 → ratio 0.5.
|
||||||
|
|
||||||
|
h.handle.truncate_to_prompt_index(1).await;
|
||||||
|
|
||||||
|
// Keeps the first user item (raw estimate 1_000) → scaled to 500.
|
||||||
|
assert_eq!(h.handle.get_total_tokens().await, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_estimate_takes_the_larger_of_text_and_ciphertext() {
|
||||||
|
// "thinking for r1" is 15 bytes; 800 base64 bytes are ~600 raw bytes.
|
||||||
|
let encrypted = reasoning_sibling("r1", Some(&"A".repeat(800)));
|
||||||
|
assert_eq!(crate::estimate_conversation_tokens(&[encrypted]), 150);
|
||||||
|
let plain = reasoning_sibling("r1", None);
|
||||||
|
assert_eq!(crate::estimate_conversation_tokens(&[plain]), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn flush_calls_persistence_flush() {
|
async fn flush_calls_persistence_flush() {
|
||||||
let mut h = TestHarness::new();
|
let mut h = TestHarness::new();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub struct KigiEndpoints {
|
|||||||
pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
|
pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
|
||||||
coding_api_base_url: "https://api.kimi.com/coding/v1",
|
coding_api_base_url: "https://api.kimi.com/coding/v1",
|
||||||
oauth_host: "https://auth.kimi.com",
|
oauth_host: "https://auth.kimi.com",
|
||||||
update_base_url: "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases",
|
update_base_url: "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases",
|
||||||
upgrade_page_url: "https://www.kimi.com/code/",
|
upgrade_page_url: "https://www.kimi.com/code/",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ impl TermWriter {
|
|||||||
pub fn writer_sync(&self) -> &WriterSync {
|
pub fn writer_sync(&self) -> &WriterSync {
|
||||||
&self.sync
|
&self.sync
|
||||||
}
|
}
|
||||||
|
/// A cloneable handle onto this writer's queue for out-of-band escapes.
|
||||||
|
pub fn escape_writer(&self) -> EscapeWriter {
|
||||||
|
EscapeWriter::new(self.tx.clone(), self.sync.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
impl Write for TermWriter {
|
impl Write for TermWriter {
|
||||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||||
@@ -166,6 +170,56 @@ impl Drop for TermWriter {
|
|||||||
let _ = self.flush();
|
let _ = self.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Out-of-band escapes ride the frame queue (an inline stderr write deadlocks on a stalled tty); drop every clone before `join_within`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EscapeWriter {
|
||||||
|
tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
sync: WriterSync,
|
||||||
|
}
|
||||||
|
impl EscapeWriter {
|
||||||
|
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
|
||||||
|
Self { tx, sync }
|
||||||
|
}
|
||||||
|
/// No writer thread behind it: sends are dropped. For tests and views built before the terminal exists.
|
||||||
|
pub fn disconnected() -> Self {
|
||||||
|
let (tx, _rx) = mpsc::channel::<Vec<u8>>();
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
sync: WriterSync::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Never blocks; covered by [`WriterSync::wait_drained`].
|
||||||
|
pub fn emit(&self, bytes: impl Into<Vec<u8>>) {
|
||||||
|
let data: Vec<u8> = bytes.into();
|
||||||
|
if data.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sync.mark_queued();
|
||||||
|
let _ = self.tx.send(data);
|
||||||
|
}
|
||||||
|
/// Winapi-only commands run synchronously: a console API call, not a tty write.
|
||||||
|
pub fn emit_command(&self, command: impl crossterm::Command) {
|
||||||
|
#[cfg(windows)]
|
||||||
|
if !command.is_ansi_code_supported() {
|
||||||
|
let _ = command.execute_winapi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut ansi = String::new();
|
||||||
|
if command.write_ansi(&mut ansi).is_ok() {
|
||||||
|
self.emit(ansi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Outcome of [`WriterThread::join_within`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WriterJoin {
|
||||||
|
/// The thread drained its queue and exited.
|
||||||
|
Joined,
|
||||||
|
/// Still running at the deadline (tty blocked, or a sender alive); detached.
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
/// Teardown waits this long for the writer thread before detaching it, so a stalled tty cannot hang `/quit`.
|
||||||
|
pub const WRITER_JOIN_GRACE: Duration = Duration::from_secs(2);
|
||||||
/// Handle for the background writer thread.
|
/// Handle for the background writer thread.
|
||||||
///
|
///
|
||||||
/// Joining ensures all queued frames have been written to the terminal
|
/// Joining ensures all queued frames have been written to the terminal
|
||||||
@@ -174,19 +228,45 @@ pub struct WriterThread {
|
|||||||
handle: Option<std::thread::JoinHandle<()>>,
|
handle: Option<std::thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
impl WriterThread {
|
impl WriterThread {
|
||||||
/// Block until the writer thread has processed all pending frames and
|
/// Wait up to `grace` for the thread to drain and exit, else detach it; every sender (`EscapeWriter` clones too) must be dropped first.
|
||||||
/// exited. The [`mpsc::Sender`] must be dropped *before* calling this,
|
pub fn join_within(mut self, grace: Duration) -> WriterJoin {
|
||||||
/// otherwise the thread will never see the channel close.
|
let Some(handle) = self.handle.take() else {
|
||||||
pub fn join(mut self) {
|
return WriterJoin::Joined;
|
||||||
if let Some(h) = self.handle.take() {
|
};
|
||||||
let _ = h.join();
|
if wait_finished(&handle, grace) {
|
||||||
|
let _ = handle.join();
|
||||||
|
WriterJoin::Joined
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
grace_ms = grace.as_millis() as u64,
|
||||||
|
"term-writer thread still running at teardown; detaching"
|
||||||
|
);
|
||||||
|
WriterJoin::TimedOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
fn for_test(handle: std::thread::JoinHandle<()>) -> Self {
|
||||||
|
Self {
|
||||||
|
handle: Some(handle),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn wait_finished(handle: &std::thread::JoinHandle<()>, grace: Duration) -> bool {
|
||||||
|
let deadline = Instant::now() + grace;
|
||||||
|
while !handle.is_finished() {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
impl Drop for WriterThread {
|
impl Drop for WriterThread {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(h) = self.handle.take() {
|
if let Some(handle) = self.handle.take()
|
||||||
let _ = h.join();
|
&& wait_finished(&handle, WRITER_JOIN_GRACE)
|
||||||
|
{
|
||||||
|
let _ = handle.join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +277,7 @@ impl Drop for WriterThread {
|
|||||||
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
|
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
|
||||||
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
|
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
|
||||||
/// Drop the sender to signal the thread to exit, then call
|
/// Drop the sender to signal the thread to exit, then call
|
||||||
/// [`WriterThread::join`] to wait for it.
|
/// [`WriterThread::join_within`] to wait for it.
|
||||||
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
|
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
|
||||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||||
let sync = WriterSync::new();
|
let sync = WriterSync::new();
|
||||||
@@ -570,4 +650,47 @@ mod tests {
|
|||||||
"Round-tripped bytes do not decode to original UTF-8 string"
|
"Round-tripped bytes do not decode to original UTF-8 string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/// Frames and escapes share one queue, so the writer sees them in call order.
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn escapes_share_the_frame_queue_in_call_order() {
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||||
|
let sync = WriterSync::new();
|
||||||
|
let mut frames = TermWriter::new(tx, sync.clone());
|
||||||
|
let escapes = frames.escape_writer();
|
||||||
|
frames.write_all(b"frame-1").unwrap();
|
||||||
|
frames.flush().unwrap();
|
||||||
|
escapes.emit("\x1b[?1000h");
|
||||||
|
frames.write_all(b"frame-2").unwrap();
|
||||||
|
frames.flush().unwrap();
|
||||||
|
escapes.emit_command(crossterm::event::DisableMouseCapture);
|
||||||
|
let payloads: Vec<Vec<u8>> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
|
||||||
|
assert_eq!(payloads.len(), 4);
|
||||||
|
assert_eq!(payloads[0], b"frame-1");
|
||||||
|
assert_eq!(payloads[1], b"\x1b[?1000h");
|
||||||
|
assert_eq!(payloads[2], b"frame-2");
|
||||||
|
assert!(payloads[3].starts_with(b"\x1b["), "{:?}", payloads[3]);
|
||||||
|
assert!(!sync.is_drained(), "four payloads queued, none written");
|
||||||
|
}
|
||||||
|
/// A writer thread parked in a blocked tty write must not hang teardown.
|
||||||
|
#[test]
|
||||||
|
fn join_within_times_out_on_a_stuck_writer_thread() {
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let _ = release_rx.recv();
|
||||||
|
});
|
||||||
|
let started = Instant::now();
|
||||||
|
let outcome = WriterThread::for_test(handle).join_within(Duration::from_millis(50));
|
||||||
|
assert_eq!(outcome, WriterJoin::TimedOut);
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(2));
|
||||||
|
let _ = release_tx.send(());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn join_within_joins_a_finished_writer_thread() {
|
||||||
|
let handle = std::thread::spawn(|| {});
|
||||||
|
assert_eq!(
|
||||||
|
WriterThread::for_test(handle).join_within(Duration::from_secs(5)),
|
||||||
|
WriterJoin::Joined
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ eventsource-stream = { workspace = true }
|
|||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
indexmap = { workspace = true, features = ["serde"] }
|
indexmap = { workspace = true, features = ["serde"] }
|
||||||
reqwest = { workspace = true, features = ["stream"] }
|
reqwest = { workspace = true, features = ["stream"] }
|
||||||
|
kigi-secrets = { path = "../kigi-secrets" }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt", "macros", "time", "sync"] }
|
tokio = { workspace = true, features = ["rt", "macros", "time", "sync"] }
|
||||||
|
|||||||
@@ -193,6 +193,16 @@ fn extract_context_total(value: &serde_json::Value) -> Option<u32> {
|
|||||||
Some(i.saturating_add(o))
|
Some(i.saturating_add(o))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The endpoint as the HTTP client parses it, minus userinfo, sensitive query values and fragment; an unparseable one is not echoed.
|
||||||
|
fn redacted_endpoint(endpoint: &str) -> String {
|
||||||
|
match reqwest::Url::parse(endpoint) {
|
||||||
|
Ok(mut url) => {
|
||||||
|
kigi_secrets::redact_url(&mut url);
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
Err(_) => "<unparseable endpoint>".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Record `success=false` + `error` on the active inference span when a stream
|
/// Record `success=false` + `error` on the active inference span when a stream
|
||||||
/// request fails before any response (transport/connect/TLS errors). Without
|
/// request fails before any response (transport/connect/TLS errors). Without
|
||||||
/// this the `#[instrument]` span closes with both fields Empty, so an outage
|
/// this the `#[instrument]` span closes with both fields Empty, so an outage
|
||||||
@@ -806,7 +816,7 @@ impl SamplingClient {
|
|||||||
let server_message_lower = server_message.to_lowercase();
|
let server_message_lower = server_message.to_lowercase();
|
||||||
|
|
||||||
let mut context_parts = vec![server_message.to_string()];
|
let mut context_parts = vec![server_message.to_string()];
|
||||||
context_parts.push(format!("\nRequest URL: {}", endpoint));
|
context_parts.push(format!("\nRequest URL: {}", redacted_endpoint(endpoint)));
|
||||||
|
|
||||||
// Show headers if error mentions headers
|
// Show headers if error mentions headers
|
||||||
if server_message_lower.contains("header") {
|
if server_message_lower.contains("header") {
|
||||||
@@ -1006,7 +1016,8 @@ impl SamplingClient {
|
|||||||
let endpoint = self.endpoint("chat/completions");
|
let endpoint = self.endpoint("chat/completions");
|
||||||
let server_message = response.text().await.unwrap_or_default();
|
let server_message = response.text().await.unwrap_or_default();
|
||||||
return Err(SamplingError::Auth(format!(
|
return Err(SamplingError::Auth(format!(
|
||||||
"Unauthorized (401) from {endpoint}: {server_message}"
|
"Unauthorized (401) from {}: {server_message}",
|
||||||
|
redacted_endpoint(&endpoint)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,7 +1207,8 @@ impl SamplingClient {
|
|||||||
let endpoint = self.endpoint("responses");
|
let endpoint = self.endpoint("responses");
|
||||||
let server_message = parse_error_bytes(bytes.as_ref());
|
let server_message = parse_error_bytes(bytes.as_ref());
|
||||||
return Err(SamplingError::Auth(format!(
|
return Err(SamplingError::Auth(format!(
|
||||||
"Unauthorized (401) from {endpoint}: {server_message}"
|
"Unauthorized (401) from {}: {server_message}",
|
||||||
|
redacted_endpoint(&endpoint)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1363,7 +1375,8 @@ impl SamplingClient {
|
|||||||
let endpoint = self.endpoint("responses");
|
let endpoint = self.endpoint("responses");
|
||||||
let server_message = response.text().await.unwrap_or_default();
|
let server_message = response.text().await.unwrap_or_default();
|
||||||
return Err(SamplingError::Auth(format!(
|
return Err(SamplingError::Auth(format!(
|
||||||
"Unauthorized (401) from {endpoint}: {server_message}"
|
"Unauthorized (401) from {}: {server_message}",
|
||||||
|
redacted_endpoint(&endpoint)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let model_metadata = extract_model_metadata(response.headers());
|
let model_metadata = extract_model_metadata(response.headers());
|
||||||
@@ -1542,7 +1555,8 @@ impl SamplingClient {
|
|||||||
let endpoint = self.endpoint("messages");
|
let endpoint = self.endpoint("messages");
|
||||||
let server_message = parse_error_bytes(bytes.as_ref());
|
let server_message = parse_error_bytes(bytes.as_ref());
|
||||||
return Err(SamplingError::Auth(format!(
|
return Err(SamplingError::Auth(format!(
|
||||||
"Unauthorized (401) from {endpoint}: {server_message}"
|
"Unauthorized (401) from {}: {server_message}",
|
||||||
|
redacted_endpoint(&endpoint)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1657,7 +1671,8 @@ impl SamplingClient {
|
|||||||
let endpoint = self.endpoint("messages");
|
let endpoint = self.endpoint("messages");
|
||||||
let server_message = response.text().await.unwrap_or_default();
|
let server_message = response.text().await.unwrap_or_default();
|
||||||
return Err(SamplingError::Auth(format!(
|
return Err(SamplingError::Auth(format!(
|
||||||
"Unauthorized (401) from {endpoint}: {server_message}"
|
"Unauthorized (401) from {}: {server_message}",
|
||||||
|
redacted_endpoint(&endpoint)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let model_metadata = extract_model_metadata(response.headers());
|
let model_metadata = extract_model_metadata(response.headers());
|
||||||
@@ -3048,3 +3063,31 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod redacted_endpoint_tests {
|
||||||
|
/// Every spelling the client accepts (LF inside, one slash, no slash) loses its userinfo.
|
||||||
|
#[test]
|
||||||
|
fn redacted_endpoint_drops_userinfo_in_every_accepted_spelling() {
|
||||||
|
for spelling in [
|
||||||
|
"http://u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
|
||||||
|
"http://u:TEST PASSWORD@127.0.0.1:1/v1/chat/completions",
|
||||||
|
"http://u:TEST_PASSWORD\n@127.0.0.1:1/v1/chat/completions",
|
||||||
|
"http://u:TEST_PASSWORD\r\n@127.0.0.1:1/v1/chat/completions",
|
||||||
|
"http:/u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
|
||||||
|
"http:u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
|
||||||
|
"http:\\u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
|
||||||
|
] {
|
||||||
|
let redacted = super::redacted_endpoint(spelling);
|
||||||
|
assert_eq!(
|
||||||
|
redacted, "http://127.0.0.1:1/v1/chat/completions",
|
||||||
|
"{spelling:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let gateway = super::redacted_endpoint(
|
||||||
|
"http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=x#frag",
|
||||||
|
);
|
||||||
|
assert!(!gateway.contains("TEST_QUERY_TOKEN"), "{gateway}");
|
||||||
|
assert!(gateway.contains("route=x"), "{gateway}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,15 @@ enum BlockType {
|
|||||||
Thinking,
|
Thinking,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executable after a `max_tokens` stop: every call's arguments parse or are empty (zero-arg convention).
|
||||||
|
fn tool_calls_complete(tool_calls: &[ToolCall]) -> bool {
|
||||||
|
!tool_calls.is_empty()
|
||||||
|
&& tool_calls.iter().all(|tc| {
|
||||||
|
tc.arguments.trim().is_empty()
|
||||||
|
|| serde_json::from_str::<serde::de::IgnoredAny>(&tc.arguments).is_ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Transform a raw Anthropic Messages API stream into a stream of
|
/// Transform a raw Anthropic Messages API stream into a stream of
|
||||||
/// [`SamplingEvent`]s.
|
/// [`SamplingEvent`]s.
|
||||||
///
|
///
|
||||||
@@ -454,11 +463,18 @@ pub fn stream_messages<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if final_stop_reason == Some(StopReason::Length) {
|
if final_stop_reason == Some(StopReason::Length) {
|
||||||
yield SamplingEvent::Failed {
|
if !tool_calls_complete(&assistant_tool_calls) {
|
||||||
request_id: request_id.clone(),
|
yield SamplingEvent::Failed {
|
||||||
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
|
request_id: request_id.clone(),
|
||||||
};
|
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
|
||||||
return;
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
request_id = %request_id,
|
||||||
|
tool_calls = assistant_tool_calls.len(),
|
||||||
|
"max_tokens stop after complete tool calls; completing the turn so they run"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the final response
|
// Build the final response
|
||||||
|
|||||||
@@ -653,3 +653,90 @@ async fn pure_cache_hit_with_zero_uncached_still_emits_usage() {
|
|||||||
assert_eq!(usage.cached_prompt_tokens, 2500);
|
assert_eq!(usage.cached_prompt_tokens, 2500);
|
||||||
assert_eq!(usage.total_tokens, 2501);
|
assert_eq!(usage.total_tokens, 2501);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_use_start(index: u32, id: &str) -> MessageStreamEvent {
|
||||||
|
MessageStreamEvent::ContentBlockStart {
|
||||||
|
index,
|
||||||
|
content_block: ContentBlock::ToolUse {
|
||||||
|
id: id.into(),
|
||||||
|
name: "do_thing".into(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arg_delta(index: u32, partial_json: &str) -> MessageStreamEvent {
|
||||||
|
MessageStreamEvent::ContentBlockDelta {
|
||||||
|
index,
|
||||||
|
delta: StreamDelta::InputJsonDelta {
|
||||||
|
partial_json: partial_json.into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn max_tokens_after(events: Vec<MessageStreamEvent>) -> Vec<SamplingEvent> {
|
||||||
|
let mut all = vec![Ok(message_start())];
|
||||||
|
all.extend(events.into_iter().map(Ok));
|
||||||
|
all.push(Ok(message_delta_with_stop(messages::StopReason::MaxTokens)));
|
||||||
|
all.push(Ok(MessageStreamEvent::MessageStop));
|
||||||
|
let raw = stream::iter(all).boxed();
|
||||||
|
collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete tool calls before a max_tokens stop complete the turn as ToolCalls.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn max_tokens_after_complete_tool_calls_completes_as_tool_calls() {
|
||||||
|
let evs = max_tokens_after(vec![
|
||||||
|
tool_use_start(0, "call_done"),
|
||||||
|
arg_delta(0, "{\"x\": 1}"),
|
||||||
|
block_stop(0),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
match evs.last().unwrap() {
|
||||||
|
SamplingEvent::Completed { response, .. } => {
|
||||||
|
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
|
||||||
|
assert_eq!(response.tool_calls().len(), 1);
|
||||||
|
assert_eq!(response.tool_calls()[0].arguments.as_ref(), "{\"x\": 1}");
|
||||||
|
}
|
||||||
|
other => panic!("expected Completed(ToolCalls), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool_use block with no argument deltas is the zero-argument convention, not truncation.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn max_tokens_after_tool_call_without_arguments_completes() {
|
||||||
|
let evs = max_tokens_after(vec![tool_use_start(0, "call_no_args"), block_stop(0)]).await;
|
||||||
|
match evs.last().unwrap() {
|
||||||
|
SamplingEvent::Completed { response, .. } => {
|
||||||
|
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
|
||||||
|
assert_eq!(response.tool_calls()[0].arguments.as_ref(), "");
|
||||||
|
}
|
||||||
|
other => panic!("expected Completed(ToolCalls), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call cut mid-arguments is truncation: the turn fails so the caller can compact.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn max_tokens_with_truncated_tool_arguments_fails() {
|
||||||
|
let evs = max_tokens_after(vec![
|
||||||
|
tool_use_start(0, "call_ok"),
|
||||||
|
arg_delta(0, "{}"),
|
||||||
|
block_stop(0),
|
||||||
|
tool_use_start(1, "call_cut"),
|
||||||
|
arg_delta(1, "{\"x\": \"trunc"),
|
||||||
|
block_stop(1),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
!evs.iter()
|
||||||
|
.any(|e| matches!(e, SamplingEvent::Completed { .. })),
|
||||||
|
"a cut tool call must not complete: {evs:?}"
|
||||||
|
);
|
||||||
|
match evs.last().unwrap() {
|
||||||
|
SamplingEvent::Failed { error, .. } => assert_eq!(
|
||||||
|
error.kind,
|
||||||
|
crate::events::SamplingErrorKind::MaxTokensTruncation
|
||||||
|
),
|
||||||
|
other => panic!("expected Failed(MaxTokensTruncation), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ enforce = []
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serial_test = { workspace = true }
|
serial_test = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
# Compiles the hand-rolled macOS deny-glob regex in tests to assert it matches
|
# Compiles the hand-rolled macOS deny-glob regex in tests to assert it matches
|
||||||
# EXACTLY the set globset (the Linux backend) matches — the cross-platform parity
|
# EXACTLY the set globset (the Linux backend) matches — the cross-platform parity
|
||||||
# guard for the glob dialect.
|
# guard for the glob dialect.
|
||||||
|
|||||||
@@ -1,91 +1,130 @@
|
|||||||
//! Per-child seccomp network filter. No-op on non-Linux.
|
//! Per-child seccomp network filter. No-op on non-Linux.
|
||||||
|
|
||||||
/// Install seccomp BPF filter blocking network syscalls.
|
#[cfg(target_os = "linux")]
|
||||||
|
mod bpf {
|
||||||
|
use libc::sock_filter;
|
||||||
|
|
||||||
|
pub(super) const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
|
||||||
|
pub(super) const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
|
||||||
|
pub(super) const EPERM_VAL: u32 = 1;
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
pub(super) const X32_SYSCALL_BIT: u32 = 0x4000_0000;
|
||||||
|
/// `seccomp_data` field offsets.
|
||||||
|
pub(super) const OFF_NR: u32 = 0;
|
||||||
|
pub(super) const OFF_ARCH: u32 = 4;
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
pub(super) const EXPECTED_ARCH: u32 = 0xc000_003e; // AUDIT_ARCH_X86_64
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
pub(super) const EXPECTED_ARCH: u32 = 0xc000_00b7; // AUDIT_ARCH_AARCH64
|
||||||
|
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||||
|
pub(super) const EXPECTED_ARCH: u32 = 0;
|
||||||
|
|
||||||
|
pub(super) fn stmt(code: u32, k: u32) -> sock_filter {
|
||||||
|
sock_filter {
|
||||||
|
code: code as u16,
|
||||||
|
jt: 0,
|
||||||
|
jf: 0,
|
||||||
|
k,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn jump(code: u32, k: u32, jt: u8, jf: u8) -> sock_filter {
|
||||||
|
sock_filter {
|
||||||
|
code: code as u16,
|
||||||
|
jt,
|
||||||
|
jf,
|
||||||
|
k,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject a foreign-arch (and, on x86_64, x32) syscall number before
|
||||||
|
/// matching `nr`, so a syscall reached through another table cannot
|
||||||
|
/// alias past the deny list.
|
||||||
|
pub(super) fn push_arch_nr_gate(f: &mut Vec<sock_filter>) {
|
||||||
|
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W};
|
||||||
|
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH));
|
||||||
|
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0));
|
||||||
|
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||||
|
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR));
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
{
|
||||||
|
f.push(jump(
|
||||||
|
BPF_JMP | libc::BPF_JSET | BPF_K,
|
||||||
|
X32_SYSCALL_BIT,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syscalls that open or send on a socket, including the `io_uring` and
|
||||||
|
/// `sendmmsg` paths that bypass the classic entry points.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn child_network_blocked_syscalls() -> [u32; 11] {
|
||||||
|
use libc::{
|
||||||
|
SYS_accept, SYS_accept4, SYS_bind, SYS_connect, SYS_io_uring_enter, SYS_io_uring_register,
|
||||||
|
SYS_io_uring_setup, SYS_listen, SYS_sendmmsg, SYS_sendmsg, SYS_sendto,
|
||||||
|
};
|
||||||
|
[
|
||||||
|
SYS_connect as u32,
|
||||||
|
SYS_bind as u32,
|
||||||
|
SYS_sendto as u32,
|
||||||
|
SYS_sendmsg as u32,
|
||||||
|
SYS_sendmmsg as u32,
|
||||||
|
SYS_listen as u32,
|
||||||
|
SYS_accept as u32,
|
||||||
|
SYS_accept4 as u32,
|
||||||
|
SYS_io_uring_setup as u32,
|
||||||
|
SYS_io_uring_enter as u32,
|
||||||
|
SYS_io_uring_register as u32,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn build_child_network_filter() -> Vec<libc::sock_filter> {
|
||||||
|
use bpf::{EPERM_VAL, SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO, jump, stmt};
|
||||||
|
use libc::{BPF_JEQ, BPF_JMP, BPF_K, BPF_RET};
|
||||||
|
|
||||||
|
let blocked = child_network_blocked_syscalls();
|
||||||
|
let mut f = Vec::with_capacity(blocked.len() + 10);
|
||||||
|
bpf::push_arch_nr_gate(&mut f);
|
||||||
|
let n = blocked.len();
|
||||||
|
for (i, &sys) in blocked.iter().enumerate() {
|
||||||
|
let remaining = n - i - 1;
|
||||||
|
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, sys, remaining as u8 + 1, 0));
|
||||||
|
}
|
||||||
|
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||||
|
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built once: `pre_exec` runs after `fork`, where allocating is unsafe.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn prebuilt_child_network_filter() -> &'static [libc::sock_filter] {
|
||||||
|
static FILTER: std::sync::OnceLock<Vec<libc::sock_filter>> = std::sync::OnceLock::new();
|
||||||
|
FILTER.get_or_init(build_child_network_filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the seccomp BPF filter blocking network syscalls.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
///
|
///
|
||||||
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
|
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
pub unsafe fn install_child_network_filter(filter: &[libc::sock_filter]) -> std::io::Result<()> {
|
||||||
use libc::{
|
use libc::{PR_SET_NO_NEW_PRIVS, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prctl, sock_fprog};
|
||||||
BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, PR_SET_NO_NEW_PRIVS,
|
|
||||||
PR_SET_SECCOMP, SECCOMP_MODE_FILTER, SYS_accept, SYS_accept4, SYS_bind, SYS_connect,
|
|
||||||
SYS_listen, SYS_sendmsg, SYS_sendto, prctl, sock_filter, sock_fprog,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
|
|
||||||
const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
|
|
||||||
// libc::EPERM
|
|
||||||
const EPERM_VAL: u32 = 1;
|
|
||||||
|
|
||||||
macro_rules! bpf_stmt {
|
|
||||||
($code:expr, $k:expr) => {
|
|
||||||
sock_filter {
|
|
||||||
code: $code as u16,
|
|
||||||
jt: 0,
|
|
||||||
jf: 0,
|
|
||||||
k: $k as u32,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! bpf_jump {
|
|
||||||
($code:expr, $k:expr, $jt:expr, $jf:expr) => {
|
|
||||||
sock_filter {
|
|
||||||
code: $code as u16,
|
|
||||||
jt: $jt,
|
|
||||||
jf: $jf,
|
|
||||||
k: $k as u32,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// seccomp_data.nr offset
|
|
||||||
const NR_OFFSET: u32 = 0;
|
|
||||||
|
|
||||||
let blocked_syscalls: &[i64] = &[
|
|
||||||
SYS_connect,
|
|
||||||
SYS_bind,
|
|
||||||
SYS_sendto,
|
|
||||||
SYS_sendmsg,
|
|
||||||
SYS_listen,
|
|
||||||
SYS_accept,
|
|
||||||
SYS_accept4,
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut filter: Vec<sock_filter> = Vec::new();
|
|
||||||
let total_checks = blocked_syscalls.len();
|
|
||||||
|
|
||||||
filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET));
|
|
||||||
|
|
||||||
for (i, &syscall) in blocked_syscalls.iter().enumerate() {
|
|
||||||
let remaining = total_checks - i - 1;
|
|
||||||
filter.push(bpf_jump!(
|
|
||||||
BPF_JMP | BPF_JEQ | BPF_K,
|
|
||||||
syscall,
|
|
||||||
// match: jump to ERRNO
|
|
||||||
remaining as u8 + 1,
|
|
||||||
// no match: check next
|
|
||||||
0
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
|
||||||
|
|
||||||
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
|
||||||
|
|
||||||
let prog = sock_fprog {
|
let prog = sock_fprog {
|
||||||
len: filter.len() as u16,
|
len: filter.len() as u16,
|
||||||
filter: filter.as_mut_ptr(),
|
filter: filter.as_ptr().cast_mut(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter
|
|
||||||
// SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context.
|
// SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context.
|
||||||
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
|
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
|
||||||
return Err(std::io::Error::last_os_error());
|
return Err(std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
|
// SAFETY: prog is a valid sock_fprog pointing to a filter the kernel copies.
|
||||||
// SAFETY: prog is a valid sock_fprog pointing to our filter array.
|
|
||||||
if unsafe {
|
if unsafe {
|
||||||
prctl(
|
prctl(
|
||||||
PR_SET_SECCOMP,
|
PR_SET_SECCOMP,
|
||||||
@@ -98,7 +137,6 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
|||||||
{
|
{
|
||||||
return Err(std::io::Error::last_os_error());
|
return Err(std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,3 +147,101 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
|||||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod tests {
|
||||||
|
use super::bpf::*;
|
||||||
|
use super::{build_child_network_filter, child_network_blocked_syscalls};
|
||||||
|
use libc::sock_filter;
|
||||||
|
|
||||||
|
/// Minimal classic-BPF interpreter over synthetic `seccomp_data` fields.
|
||||||
|
fn eval(filter: &[sock_filter], arch: u32, nr: u32) -> u32 {
|
||||||
|
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W};
|
||||||
|
let mut pc = 0usize;
|
||||||
|
let mut a = 0u32;
|
||||||
|
for _ in 0..filter.len().saturating_mul(2) {
|
||||||
|
let insn = &filter[pc];
|
||||||
|
let op = insn.code as u32;
|
||||||
|
if op == (BPF_LD | BPF_W | BPF_ABS) {
|
||||||
|
a = match insn.k {
|
||||||
|
OFF_NR => nr,
|
||||||
|
OFF_ARCH => arch,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
pc += 1;
|
||||||
|
} else if op == (BPF_JMP | BPF_JEQ | BPF_K) {
|
||||||
|
pc = if a == insn.k {
|
||||||
|
pc + 1 + insn.jt as usize
|
||||||
|
} else {
|
||||||
|
pc + 1 + insn.jf as usize
|
||||||
|
};
|
||||||
|
} else if op == (BPF_JMP | BPF_JSET | BPF_K) {
|
||||||
|
pc = if a & insn.k != 0 {
|
||||||
|
pc + 1 + insn.jt as usize
|
||||||
|
} else {
|
||||||
|
pc + 1 + insn.jf as usize
|
||||||
|
};
|
||||||
|
} else if op == (BPF_RET | BPF_K) {
|
||||||
|
return insn.k;
|
||||||
|
} else {
|
||||||
|
panic!("unsupported opcode {:#x} at {pc}", insn.code);
|
||||||
|
}
|
||||||
|
assert!(pc < filter.len(), "pc out of range");
|
||||||
|
}
|
||||||
|
panic!("filter did not RET");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_allow(r: u32) -> bool {
|
||||||
|
r == SECCOMP_RET_ALLOW
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_eperm(r: u32) -> bool {
|
||||||
|
r == (SECCOMP_RET_ERRNO | EPERM_VAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_every_network_syscall_and_allows_read_and_socket() {
|
||||||
|
let f = build_child_network_filter();
|
||||||
|
for sys in child_network_blocked_syscalls() {
|
||||||
|
assert!(
|
||||||
|
is_eperm(eval(&f, EXPECTED_ARCH, sys)),
|
||||||
|
"syscall {sys} must be EPERM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_read as u32)));
|
||||||
|
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_socket as u32)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn covers_io_uring_and_sendmmsg() {
|
||||||
|
let blocked = child_network_blocked_syscalls();
|
||||||
|
for sys in [
|
||||||
|
libc::SYS_sendmmsg,
|
||||||
|
libc::SYS_io_uring_setup,
|
||||||
|
libc::SYS_io_uring_enter,
|
||||||
|
libc::SYS_io_uring_register,
|
||||||
|
] {
|
||||||
|
assert!(blocked.contains(&(sys as u32)), "{sys} missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_arch_and_x32_are_denied_before_the_nr_match() {
|
||||||
|
let f = build_child_network_filter();
|
||||||
|
assert!(is_eperm(eval(&f, 0xdead_beef, libc::SYS_read as u32)));
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
assert!(is_eperm(eval(
|
||||||
|
&f,
|
||||||
|
EXPECTED_ARCH,
|
||||||
|
(libc::SYS_read as u32) | X32_SYSCALL_BIT
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prebuilt_filter_is_shared() {
|
||||||
|
let a = super::prebuilt_child_network_filter();
|
||||||
|
let b = super::prebuilt_child_network_filter();
|
||||||
|
assert!(std::ptr::eq(a, b));
|
||||||
|
assert_eq!(a.len(), build_child_network_filter().len());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,6 +333,27 @@ fn is_devbox_based(profile: &ProfileName, config: &SandboxConfig) -> bool {
|
|||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Whether an existing `/data` needs the devbox bwrap read-only bind; a filesystem error other than NotFound conservatively requires it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn requires_data_write_deny(profile: &ProfileName, workspace: &Path) -> bool {
|
||||||
|
requires_data_write_deny_for(
|
||||||
|
profile,
|
||||||
|
&profiles::load_sandbox_config(workspace),
|
||||||
|
data_path_requires_bind(Path::new("/data")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn requires_data_write_deny_for(
|
||||||
|
profile: &ProfileName,
|
||||||
|
config: &SandboxConfig,
|
||||||
|
is_data_present: bool,
|
||||||
|
) -> bool {
|
||||||
|
is_devbox_based(profile, config) && is_data_present
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn data_path_requires_bind(path: &Path) -> bool {
|
||||||
|
path.try_exists().unwrap_or(true)
|
||||||
|
}
|
||||||
/// Whether kernel read-deny enforcement is required. The single source of truth
|
/// Whether kernel read-deny enforcement is required. The single source of truth
|
||||||
/// for this classification so callers (e.g. the shell's fail-closed startup path)
|
/// for this classification so callers (e.g. the shell's fail-closed startup path)
|
||||||
/// cannot drift and silently fail open.
|
/// cannot drift and silently fail open.
|
||||||
@@ -360,6 +381,23 @@ pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool {
|
|||||||
pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool {
|
pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
/// Whether the resolved profile leaves `~/.kigi` read-only outside its
|
||||||
|
/// state dirs (strict and every profile extending it), so files first use
|
||||||
|
/// would write there must be minted before enforcement.
|
||||||
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
|
pub fn restricts_home_writes(profile: &ProfileName, workspace: &Path) -> bool {
|
||||||
|
if *profile == ProfileName::Off {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let config = profiles::load_sandbox_config(workspace);
|
||||||
|
profile
|
||||||
|
.resolve_profile(workspace, &config)
|
||||||
|
.is_ok_and(|resolved| !resolved.read_write.contains(&paths::kigi_home()))
|
||||||
|
}
|
||||||
|
#[cfg(not(all(feature = "enforce", unix)))]
|
||||||
|
pub fn restricts_home_writes(_profile: &ProfileName, _workspace: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`),
|
/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`),
|
||||||
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
|
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
|
||||||
/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero
|
/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero
|
||||||
@@ -382,7 +420,11 @@ struct BwrapDenyPlan {
|
|||||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||||
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
||||||
let config = profiles::load_sandbox_config(workspace);
|
let config = profiles::load_sandbox_config(workspace);
|
||||||
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
|
let deny_write: Vec<String> = if requires_data_write_deny_for(
|
||||||
|
profile,
|
||||||
|
&config,
|
||||||
|
data_path_requires_bind(Path::new("/data")),
|
||||||
|
) {
|
||||||
vec!["/data".to_string()]
|
vec!["/data".to_string()]
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -424,7 +466,11 @@ fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyP
|
|||||||
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
||||||
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
||||||
let config = profiles::load_sandbox_config(workspace);
|
let config = profiles::load_sandbox_config(workspace);
|
||||||
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
|
let deny_write: Vec<String> = if requires_data_write_deny_for(
|
||||||
|
profile,
|
||||||
|
&config,
|
||||||
|
data_path_requires_bind(Path::new("/data")),
|
||||||
|
) {
|
||||||
vec!["/data".to_string()]
|
vec!["/data".to_string()]
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -595,6 +641,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn data_write_deny_requires_devbox_and_a_present_data_dir() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(tmp.path().join(".kigi")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
tmp.path().join(".kigi/sandbox.toml"),
|
||||||
|
"[profiles.devext]\nextends = \"devbox\"\n\n[profiles.plain]\nextends = \"workspace\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = profiles::load_sandbox_config(tmp.path());
|
||||||
|
assert!(requires_data_write_deny_for(
|
||||||
|
&ProfileName::Devbox,
|
||||||
|
&config,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!requires_data_write_deny_for(
|
||||||
|
&ProfileName::Devbox,
|
||||||
|
&config,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(requires_data_write_deny_for(
|
||||||
|
&ProfileName::Custom("devext".into()),
|
||||||
|
&config,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!requires_data_write_deny_for(
|
||||||
|
&ProfileName::Custom("plain".into()),
|
||||||
|
&config,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!requires_data_write_deny_for(
|
||||||
|
&ProfileName::Workspace,
|
||||||
|
&config,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn configured_profile_is_recorded() {
|
fn configured_profile_is_recorded() {
|
||||||
set_configured_profile("read-only");
|
set_configured_profile("read-only");
|
||||||
assert_eq!(configured_profile_name(), Some("read-only"));
|
assert_eq!(configured_profile_name(), Some("read-only"));
|
||||||
@@ -700,10 +783,11 @@ mod tests {
|
|||||||
"devbox-empty",
|
"devbox-empty",
|
||||||
"[profiles.devempty]\nextends = \"devbox\"\n",
|
"[profiles.devempty]\nextends = \"devbox\"\n",
|
||||||
);
|
);
|
||||||
assert!(
|
assert_eq!(
|
||||||
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
|
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
|
||||||
.is_some(),
|
.is_some(),
|
||||||
"devbox-extending custom must compose the /data write-deny re-exec"
|
Path::new("/data").exists(),
|
||||||
|
"devbox-extending custom composes the /data write-deny re-exec exactly when /data exists"
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&ws_empty);
|
let _ = std::fs::remove_dir_all(&ws_empty);
|
||||||
let ws_ws = temp_workspace_with_sandbox_toml(
|
let ws_ws = temp_workspace_with_sandbox_toml(
|
||||||
@@ -717,3 +801,31 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&ws_ws);
|
let _ = std::fs::remove_dir_all(&ws_ws);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "enforce", unix))]
|
||||||
|
mod home_write_tests {
|
||||||
|
use super::{ProfileName, restricts_home_writes};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_restricts_home_writes_and_workspace_does_not() {
|
||||||
|
let workspace = std::env::temp_dir();
|
||||||
|
assert!(restricts_home_writes(&ProfileName::Strict, &workspace));
|
||||||
|
assert!(!restricts_home_writes(&ProfileName::Workspace, &workspace));
|
||||||
|
assert!(!restricts_home_writes(&ProfileName::ReadOnly, &workspace));
|
||||||
|
assert!(!restricts_home_writes(&ProfileName::Off, &workspace));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_profile_extending_strict_inherits_the_restriction() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project = tmp.path().join(".kigi");
|
||||||
|
std::fs::create_dir_all(&project).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
project.join("sandbox.toml"),
|
||||||
|
"[profiles.audit-home-write-test]\nextends = \"strict\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let custom = ProfileName::Custom("audit-home-write-test".to_string());
|
||||||
|
assert!(restricts_home_writes(&custom, tmp.path()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Sandbox event logger.
|
//! Sandbox event logger.
|
||||||
//!
|
//!
|
||||||
//! Events (profile applied, violations, bypasses) are buffered in memory and
|
//! Events (profile applied, violations, bypasses) are buffered in memory and
|
||||||
//! flushed as JSONL to `~/.kigi/sandbox-events.jsonl`.
|
//! flushed as JSONL to `~/.kigi/sessions/sandbox-events.jsonl`.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -87,8 +87,12 @@ impl SandboxLogger {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Under `sessions/`: the only home subtree a strict sandbox leaves
|
||||||
|
/// writable, and this flush runs after enforcement.
|
||||||
fn log_file_path() -> PathBuf {
|
fn log_file_path() -> PathBuf {
|
||||||
kigi_config::kigi_home().join("sandbox-events.jsonl")
|
kigi_config::kigi_home()
|
||||||
|
.join("sessions")
|
||||||
|
.join("sandbox-events.jsonl")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,37 @@ pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec<PathBuf> {
|
|||||||
paths
|
paths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Home subtrees the process keeps writing after enforcement: session and
|
||||||
|
/// worktree state, memory, diagnostics. Landlock cannot carve a child out of
|
||||||
|
/// a write grant, so the rest of `~/.kigi` (`config.toml`, `auth.json`,
|
||||||
|
/// `sandbox.toml`, `hooks/`, `bin/`, `vendor/`, `plugin-data/`) stays
|
||||||
|
/// read-only and a sandboxed child cannot arm the next launch.
|
||||||
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
|
const HOME_STATE_DIRS: &[&str] = &[
|
||||||
|
"run",
|
||||||
|
"sessions",
|
||||||
|
"worktrees",
|
||||||
|
"worktree_pool",
|
||||||
|
"tasks",
|
||||||
|
"memory",
|
||||||
|
"logs",
|
||||||
|
"debug",
|
||||||
|
"crash",
|
||||||
|
"memtrace",
|
||||||
|
"trace-exports",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Writable paths for the strict profile: the workspace, the home state
|
||||||
|
/// subtrees, and temp.
|
||||||
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
|
pub(crate) fn essential_writable_paths_strict(workspace: &Path) -> Vec<PathBuf> {
|
||||||
|
let home = kigi_home();
|
||||||
|
let mut paths = vec![workspace.to_path_buf()];
|
||||||
|
paths.extend(HOME_STATE_DIRS.iter().map(|dir| home.join(dir)));
|
||||||
|
paths.extend(temp_writable_paths());
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
/// Writable directory paths for the read-only profile (minimal: just ~/.kigi + temp).
|
/// Writable directory paths for the read-only profile (minimal: just ~/.kigi + temp).
|
||||||
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
|
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
|
||||||
#[cfg(all(feature = "enforce", unix))]
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::paths::kigi_home;
|
|||||||
#[cfg(all(feature = "enforce", unix))]
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
use crate::paths::{
|
use crate::paths::{
|
||||||
DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
|
DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
|
||||||
|
essential_writable_paths_strict,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
|
/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
|
||||||
@@ -186,6 +187,32 @@ fn load_config_file(path: &Path) -> Option<SandboxConfig> {
|
|||||||
#[cfg(all(feature = "enforce", unix))]
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
impl ProfileName {
|
impl ProfileName {
|
||||||
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
|
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
|
||||||
|
/// The directory a `read_write` entry really grants. A symlink under
|
||||||
|
/// `~/.kigi` is followed only to the default `~/.kigi/sessions` (the
|
||||||
|
/// `KIGI_SHARE_DIR` override case); any other target would widen the
|
||||||
|
/// grant to wherever the link points. Missing paths are created.
|
||||||
|
fn read_write_grant_path(path: &Path, home: &Path) -> Option<PathBuf> {
|
||||||
|
match std::fs::symlink_metadata(path) {
|
||||||
|
Ok(meta) if meta.file_type().is_symlink() => {
|
||||||
|
if path == home || !path.starts_with(home) {
|
||||||
|
return Some(path.to_path_buf());
|
||||||
|
}
|
||||||
|
let real = dunce::canonicalize(path).ok()?;
|
||||||
|
let default_sessions = kigi_config::default_kigi_home().join("sessions");
|
||||||
|
(real.is_dir()
|
||||||
|
&& path.file_name() == Some(std::ffi::OsStr::new("sessions"))
|
||||||
|
&& real == default_sessions)
|
||||||
|
.then_some(real)
|
||||||
|
}
|
||||||
|
Ok(_) => Some(path.to_path_buf()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
std::fs::create_dir_all(path).ok()?;
|
||||||
|
Some(path.to_path_buf())
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
|
pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
|
||||||
let config = load_sandbox_config(workspace);
|
let config = load_sandbox_config(workspace);
|
||||||
self.to_capability_set_with_config(workspace, &config)
|
self.to_capability_set_with_config(workspace, &config)
|
||||||
@@ -231,13 +258,14 @@ impl ProfileName {
|
|||||||
// apply time (it opens an O_PATH fd), but new files within it can
|
// apply time (it opens an O_PATH fd), but new files within it can
|
||||||
// be created freely after the sandbox is applied. Pre-create
|
// be created freely after the sandbox is applied. Pre-create
|
||||||
// directories like ~/.kigi/ that may not exist on first run.
|
// directories like ~/.kigi/ that may not exist on first run.
|
||||||
|
let home = kigi_home();
|
||||||
for path in &profile.read_write {
|
for path in &profile.read_write {
|
||||||
if !path.exists() && std::fs::create_dir_all(path).is_err() {
|
let Some(grant) = Self::read_write_grant_path(path, &home) else {
|
||||||
tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping");
|
tracing::warn!(path = ?path, "read_write grant skipped");
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let Some(path_str) = path.to_str() else {
|
let Some(path_str) = grant.to_str() else {
|
||||||
tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path");
|
tracing::warn!(path = ?grant, "Skipping non-UTF8 read_write path");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
|
caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
|
||||||
@@ -380,12 +408,15 @@ impl ProfileName {
|
|||||||
.chain(std::iter::once(home.join("Library")))
|
.chain(std::iter::once(home.join("Library")))
|
||||||
.filter(|p| p.exists())
|
.filter(|p| p.exists())
|
||||||
.chain(std::iter::once(workspace.to_path_buf()))
|
.chain(std::iter::once(workspace.to_path_buf()))
|
||||||
|
// Read-only: config, auth and global hooks are read after
|
||||||
|
// enforcement; only sessions/ below stays writable.
|
||||||
|
.chain(std::iter::once(kigi_home()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(SandboxProfile {
|
Ok(SandboxProfile {
|
||||||
name: "strict".to_string(),
|
name: "strict".to_string(),
|
||||||
read_only: system_read,
|
read_only: system_read,
|
||||||
read_write: essential_writable_paths(workspace),
|
read_write: essential_writable_paths_strict(workspace),
|
||||||
deny: vec![],
|
deny: vec![],
|
||||||
default_read: false,
|
default_read: false,
|
||||||
restrict_network: true,
|
restrict_network: true,
|
||||||
@@ -544,6 +575,82 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
|
fn strict_read_write_is_workspace_home_state_and_temp() {
|
||||||
|
let workspace = std::env::temp_dir();
|
||||||
|
let profile = ProfileName::Strict
|
||||||
|
.resolve_profile(&workspace, &SandboxConfig::default())
|
||||||
|
.expect("strict resolves");
|
||||||
|
let home = kigi_home();
|
||||||
|
assert_eq!(profile.read_write[0], workspace);
|
||||||
|
for dir in ["sessions", "worktrees", "memory", "logs", "debug"] {
|
||||||
|
assert!(
|
||||||
|
profile.read_write.contains(&home.join(dir)),
|
||||||
|
"{dir} must stay writable: {:?}",
|
||||||
|
profile.read_write
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for denied in [
|
||||||
|
home.clone(),
|
||||||
|
home.join("hooks"),
|
||||||
|
home.join("vendor"),
|
||||||
|
home.join("bin"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!profile.read_write.contains(&denied),
|
||||||
|
"strict must not grant {}: {:?}",
|
||||||
|
denied.display(),
|
||||||
|
profile.read_write
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
profile.read_only.contains(&home),
|
||||||
|
"config/auth/hooks are read after enforcement: {:?}",
|
||||||
|
profile.read_only
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
|
fn read_write_grant_follows_real_dirs_and_creates_missing_ones() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
std::fs::create_dir_all(home.join("sessions")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ProfileName::read_write_grant_path(&home.join("sessions"), &home),
|
||||||
|
Some(home.join("sessions"))
|
||||||
|
);
|
||||||
|
let missing = home.join("fresh");
|
||||||
|
assert_eq!(
|
||||||
|
ProfileName::read_write_grant_path(&missing, &home),
|
||||||
|
Some(missing.clone())
|
||||||
|
);
|
||||||
|
assert!(missing.is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
|
fn read_write_grant_refuses_a_home_symlink_pointing_elsewhere() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let outside = tmp.path().join("outside");
|
||||||
|
std::fs::create_dir_all(&home).unwrap();
|
||||||
|
std::fs::create_dir_all(&outside).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&outside, home.join("sessions")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ProfileName::read_write_grant_path(&home.join("sessions"), &home),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// A symlinked workspace outside the home is the user's own choice.
|
||||||
|
let ws_link = tmp.path().join("ws");
|
||||||
|
std::os::unix::fs::symlink(&outside, &ws_link).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ProfileName::read_write_grant_path(&ws_link, &home),
|
||||||
|
Some(ws_link.clone())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(all(feature = "enforce", unix))]
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
fn base_profile_capability_set_builds() {
|
fn base_profile_capability_set_builds() {
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ fs2 = { workspace = true }
|
|||||||
zstd = { workspace = true }
|
zstd = { workspace = true }
|
||||||
tar = { workspace = true }
|
tar = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
kigi-secrets = { path = "../kigi-secrets" }
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
tdigests = "1.0"
|
tdigests = "1.0"
|
||||||
semver = { workspace = true }
|
semver = { workspace = true }
|
||||||
|
|||||||
@@ -2101,7 +2101,7 @@ kigi --sandbox strict
|
|||||||
| `off` (default) | Unrestricted | Unrestricted | Unrestricted | No sandbox |
|
| `off` (default) | Unrestricted | Unrestricted | Unrestricted | No sandbox |
|
||||||
| `workspace` | Everywhere | CWD + `/tmp` + `~/.kigi/` | Allowed | Normal development |
|
| `workspace` | Everywhere | CWD + `/tmp` + `~/.kigi/` | Allowed | Normal development |
|
||||||
| `read-only` | Everywhere | `~/.kigi/` only | Blocked | Exploration, code review |
|
| `read-only` | Everywhere | `~/.kigi/` only | Blocked | Exploration, code review |
|
||||||
| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` | Blocked | Untrusted code |
|
| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` state dirs | Blocked | Untrusted code |
|
||||||
|
|
||||||
Sensitive paths (`~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.kigi/auth/`) are always
|
Sensitive paths (`~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.kigi/auth/`) are always
|
||||||
write-protected regardless of profile.
|
write-protected regardless of profile.
|
||||||
@@ -2160,7 +2160,7 @@ model cannot convince the agent to relax restrictions at runtime.
|
|||||||
|
|
||||||
### Event Logging
|
### Event Logging
|
||||||
|
|
||||||
Sandbox events (profile applied, violations) are logged to `~/.kigi/sandbox-events.jsonl`
|
Sandbox events (profile applied, violations) are logged to `~/.kigi/sessions/sandbox-events.jsonl`
|
||||||
for telemetry and debugging.
|
for telemetry and debugging.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2601,6 +2601,7 @@ pub(crate) fn resolve_model_list(
|
|||||||
}
|
}
|
||||||
resolved = prefetched;
|
resolved = prefetched;
|
||||||
}
|
}
|
||||||
|
let mut explicit_api_backend_keys = std::collections::HashSet::new();
|
||||||
for (key, model_override) in &cfg.config_models {
|
for (key, model_override) in &cfg.config_models {
|
||||||
let had_base = resolved.contains_key(key);
|
let had_base = resolved.contains_key(key);
|
||||||
let base = resolved.shift_remove(key);
|
let base = resolved.shift_remove(key);
|
||||||
@@ -2617,6 +2618,9 @@ pub(crate) fn resolve_model_list(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if model_override.api_backend.is_some() {
|
||||||
|
explicit_api_backend_keys.insert(key.as_str());
|
||||||
|
}
|
||||||
let entry = model_override.apply(key, base, &cfg.endpoints);
|
let entry = model_override.apply(key, base, &cfg.endpoints);
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
|
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
|
||||||
@@ -2638,7 +2642,7 @@ pub(crate) fn resolve_model_list(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
for entry in resolved.values_mut() {
|
for (key, entry) in resolved.iter_mut() {
|
||||||
if let Some((donor_cw, donor_backend)) = donors.get(&entry.info.model) {
|
if let Some((donor_cw, donor_backend)) = donors.get(&entry.info.model) {
|
||||||
if entry.info.context_window.get() == default_cw {
|
if entry.info.context_window.get() == default_cw {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -2648,7 +2652,9 @@ pub(crate) fn resolve_model_list(
|
|||||||
);
|
);
|
||||||
entry.info.context_window = *donor_cw;
|
entry.info.context_window = *donor_cw;
|
||||||
}
|
}
|
||||||
if entry.info.api_backend == ApiBackend::default()
|
// An explicit `[model.X] api_backend` wins even when it is the default.
|
||||||
|
if !explicit_api_backend_keys.contains(key.as_str())
|
||||||
|
&& entry.info.api_backend == ApiBackend::default()
|
||||||
&& *donor_backend != ApiBackend::default()
|
&& *donor_backend != ApiBackend::default()
|
||||||
{
|
{
|
||||||
entry.info.api_backend.clone_from(donor_backend);
|
entry.info.api_backend.clone_from(donor_backend);
|
||||||
@@ -9067,6 +9073,47 @@ default = "kigi-4.5"
|
|||||||
sibling 'kigi-build' (same model slug), not stay at {default_cw}"
|
sibling 'kigi-build' (same model slug), not stay at {default_cw}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/// An explicit `[model.X] api_backend` survives a same-slug sibling even when it is the default.
|
||||||
|
#[test]
|
||||||
|
fn slug_propagation_keeps_an_explicit_default_api_backend() {
|
||||||
|
// Only kigi-messages has a non-default context window: the slug's single donor.
|
||||||
|
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||||||
|
let raw: toml::Value = toml::from_str(&format!(
|
||||||
|
r#"
|
||||||
|
[model.kigi-messages]
|
||||||
|
model = "kigi-4.5"
|
||||||
|
context_window = 500000
|
||||||
|
base_url = "https://test.example.com/v1"
|
||||||
|
api_backend = "messages"
|
||||||
|
|
||||||
|
[model.kigi-chat]
|
||||||
|
model = "kigi-4.5"
|
||||||
|
context_window = {default_cw}
|
||||||
|
base_url = "https://test.example.com/v1"
|
||||||
|
api_backend = "chat_completions"
|
||||||
|
|
||||||
|
[model.kigi-plain]
|
||||||
|
model = "kigi-4.5"
|
||||||
|
context_window = {default_cw}
|
||||||
|
base_url = "https://test.example.com/v1"
|
||||||
|
"#
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||||||
|
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||||||
|
let backend = |key: &str| resolved.get(key).expect(key).info.api_backend.clone();
|
||||||
|
assert_eq!(
|
||||||
|
backend("kigi-chat"),
|
||||||
|
ApiBackend::ChatCompletions,
|
||||||
|
"explicit default backend must not be overwritten by the sibling"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend("kigi-plain"),
|
||||||
|
ApiBackend::Messages,
|
||||||
|
"unset backend still inherits"
|
||||||
|
);
|
||||||
|
assert_eq!(backend("kigi-messages"), ApiBackend::Messages);
|
||||||
|
}
|
||||||
/// Slug propagation should carry over api_backend but NOT agent_type.
|
/// Slug propagation should carry over api_backend but NOT agent_type.
|
||||||
#[test]
|
#[test]
|
||||||
fn slug_propagation_inherits_api_backend_but_not_agent_type() {
|
fn slug_propagation_inherits_api_backend_but_not_agent_type() {
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ use std::sync::Arc;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
/// Where non-subscription users (no OAuth session) submit feedback instead.
|
/// Where non-subscription users (no OAuth session) submit feedback instead.
|
||||||
pub const FEEDBACK_ISSUES_URL: &str =
|
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
|
||||||
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues";
|
|
||||||
|
|
||||||
/// HTTP error from the feedback endpoint with a preserved status code, so
|
/// HTTP error from the feedback endpoint with a preserved status code, so
|
||||||
/// callers can distinguish auth failures (401) without string matching.
|
/// callers can distinguish auth failures (401) without string matching.
|
||||||
|
|||||||
@@ -210,9 +210,9 @@ pub(crate) async fn apply(
|
|||||||
auto_compact_threshold_percent: new_threshold,
|
auto_compact_threshold_percent: new_threshold,
|
||||||
responds_to: tx,
|
responds_to: tx,
|
||||||
});
|
});
|
||||||
let updated_model = rx
|
let (updated_model, context_window) = rx
|
||||||
.await
|
.await
|
||||||
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))?;
|
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))??;
|
||||||
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
|
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
|
||||||
handle.model_id = model_id.clone();
|
handle.model_id = model_id.clone();
|
||||||
handle.reasoning_effort = applied_effort;
|
handle.reasoning_effort = applied_effort;
|
||||||
@@ -224,6 +224,7 @@ pub(crate) async fn apply(
|
|||||||
&session_id,
|
&session_id,
|
||||||
model_id.0.as_ref(),
|
model_id.0.as_ref(),
|
||||||
applied_effort.map(|eff| eff.to_string()),
|
applied_effort.map(|eff| eff.to_string()),
|
||||||
|
context_window.get(),
|
||||||
);
|
);
|
||||||
if agent.cfg.borrow().mode != config::AgentMode::Leader {
|
if agent.cfg.borrow().mode != config::AgentMode::Leader {
|
||||||
agent.models_manager.set_current_model_id(model_id);
|
agent.models_manager.set_current_model_id(model_id);
|
||||||
@@ -232,9 +233,12 @@ pub(crate) async fn apply(
|
|||||||
.set_current_reasoning_effort(applied_effort);
|
.set_current_reasoning_effort(applied_effort);
|
||||||
}
|
}
|
||||||
Ok(acp::SetSessionModelResponse::new().meta(
|
Ok(acp::SetSessionModelResponse::new().meta(
|
||||||
serde_json::json!({ "model" : updated_model, })
|
serde_json::json!({
|
||||||
.as_object()
|
"model": updated_model,
|
||||||
.cloned(),
|
"totalContextTokens": context_window.get(),
|
||||||
|
})
|
||||||
|
.as_object()
|
||||||
|
.cloned(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
/// Broadcast a `ModelChanged` to every client subscribed to this session so
|
/// Broadcast a `ModelChanged` to every client subscribed to this session so
|
||||||
@@ -245,12 +249,14 @@ fn broadcast_model_changed(
|
|||||||
session_id: &acp::SessionId,
|
session_id: &acp::SessionId,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
reasoning_effort: Option<String>,
|
reasoning_effort: Option<String>,
|
||||||
|
context_window: u64,
|
||||||
) {
|
) {
|
||||||
let notification = crate::extensions::notification::SessionNotification {
|
let notification = crate::extensions::notification::SessionNotification {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
update: crate::extensions::notification::SessionUpdate::ModelChanged {
|
update: crate::extensions::notification::SessionUpdate::ModelChanged {
|
||||||
model_id: model_id.to_owned(),
|
model_id: model_id.to_owned(),
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
|
context_window: Some(context_window),
|
||||||
},
|
},
|
||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1365,7 +1365,8 @@ impl MvpAgent {
|
|||||||
/// `FlushAndAck` on the persistence actor — a true sync barrier that only
|
/// `FlushAndAck` on the persistence actor — a true sync barrier that only
|
||||||
/// resolves after all queued writes (chat messages, updates) hit disk.
|
/// resolves after all queued writes (chat messages, updates) hit disk.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(())` on success, `Err(reason)` on timeout or channel failure.
|
/// Returns `Ok(())` once the writes are on disk, `Err(reason)` on a failed
|
||||||
|
/// flush, timeout or channel failure.
|
||||||
pub(crate) async fn flush_session(
|
pub(crate) async fn flush_session(
|
||||||
&self,
|
&self,
|
||||||
session_id: &acp::SessionId,
|
session_id: &acp::SessionId,
|
||||||
@@ -1384,7 +1385,11 @@ impl MvpAgent {
|
|||||||
return Err("send failed");
|
return Err("send failed");
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
|
match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
|
||||||
Ok(Ok(_)) => Ok(()),
|
Ok(Ok(Ok(()))) => Ok(()),
|
||||||
|
Ok(Ok(Err(error))) => {
|
||||||
|
tracing::error!(session_id = %session_id.0, %error, "persistence flush failed");
|
||||||
|
Err("persistence flush failed")
|
||||||
|
}
|
||||||
Ok(Err(_)) => Err("channel closed"),
|
Ok(Err(_)) => Err("channel closed"),
|
||||||
Err(_) => Err("timeout"),
|
Err(_) => Err("timeout"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
|
|||||||
kigi_workspace::permission::spawn_permission_manager(
|
kigi_workspace::permission::spawn_permission_manager(
|
||||||
sid.clone(),
|
sid.clone(),
|
||||||
gateway,
|
gateway,
|
||||||
cwd,
|
cwd.clone(),
|
||||||
kigi_workspace::permission::types::ClientType::Generic,
|
kigi_workspace::permission::types::ClientType::Generic,
|
||||||
Some(PermissionConfig::new(vec![PermissionRule {
|
Some(PermissionConfig::new(vec![PermissionRule {
|
||||||
action: RuleAction::Deny,
|
action: RuleAction::Deny,
|
||||||
@@ -64,6 +64,8 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
|
|||||||
Some("child-session".to_owned()),
|
Some("child-session".to_owned()),
|
||||||
Some("general-purpose".to_owned()),
|
Some("general-purpose".to_owned()),
|
||||||
Some("permission inheritance regression".to_owned()),
|
Some("permission inheritance regression".to_owned()),
|
||||||
|
cwd.clone(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -19,6 +19,99 @@ use kigi_tools::implementations::kigi::task::types::*;
|
|||||||
use kigi_workspace::file_system::AsyncFileSystem;
|
use kigi_workspace::file_system::AsyncFileSystem;
|
||||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Bounds each parent-side await in the child completion path so a starved parent `cmd_rx` cannot park a completed child forever.
|
||||||
|
pub(super) const PARENT_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
/// Bounds reads of the child's own actors, which freeze with a tool that blocks the child thread; teardown must still reach `Shutdown`.
|
||||||
|
pub(super) const CHILD_ACTOR_ACK_TIMEOUT: std::time::Duration =
|
||||||
|
std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// One bounded child-actor query, degrading to `fallback` when the actor never answers.
|
||||||
|
pub(super) async fn child_actor_query<T>(
|
||||||
|
what: &'static str,
|
||||||
|
query: impl std::future::Future<Output = T>,
|
||||||
|
fallback: T,
|
||||||
|
) -> T {
|
||||||
|
match tokio::time::timeout(CHILD_ACTOR_ACK_TIMEOUT, query).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(query = what, "child actor did not answer in time; using fallback");
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounds the post-Shutdown worktree snapshot (a `git add` behind a stuck clean filter must not park completion).
|
||||||
|
const WORKTREE_SNAPSHOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// Snapshot the child worktree to `ref_name`, persist the ref, then remove the worktree; returns `(snapshot_ref, removed)`.
|
||||||
|
async fn dispose_subagent_worktree(
|
||||||
|
worktree: &Path,
|
||||||
|
source_repo: &Path,
|
||||||
|
ref_name: &str,
|
||||||
|
meta_dir: &Path,
|
||||||
|
final_status: &str,
|
||||||
|
subagent_id: &str,
|
||||||
|
) -> (Option<String>, bool) {
|
||||||
|
let snapshot = tokio::time::timeout(
|
||||||
|
WORKTREE_SNAPSHOT_TIMEOUT,
|
||||||
|
crate::session::worktree::snapshot_subagent_worktree(worktree, source_repo, ref_name),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let snapshot_ref = match snapshot {
|
||||||
|
Ok(Ok(snapshot_ref)) => snapshot_ref,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
|
||||||
|
"Failed to snapshot subagent worktree; preserving for review"
|
||||||
|
);
|
||||||
|
return (None, false);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
subagent_id = % subagent_id, worktree_path = % worktree.display(),
|
||||||
|
"worktree snapshot did not finish in time; preserving worktree for resume"
|
||||||
|
);
|
||||||
|
return (None, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !update_subagent_meta_snapshot_ref(meta_dir, &snapshot_ref, final_status) {
|
||||||
|
tracing::warn!(
|
||||||
|
subagent_id = % subagent_id, worktree_path = % worktree.display(),
|
||||||
|
"snapshot_ref not persisted; preserving worktree for resume"
|
||||||
|
);
|
||||||
|
return (None, false);
|
||||||
|
}
|
||||||
|
// Removal is awaited in full: a detached deletion could race a resume that rehydrates into this path.
|
||||||
|
match crate::session::worktree::remove_subagent_worktree(worktree).await {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
subagent_id = % subagent_id, worktree_path = % worktree.display(),
|
||||||
|
"snapshotted and removed subagent worktree"
|
||||||
|
);
|
||||||
|
(Some(snapshot_ref), true)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
|
||||||
|
"snapshotted subagent worktree but removal failed; ref persisted for resume"
|
||||||
|
);
|
||||||
|
(Some(snapshot_ref), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Await a parent ack for at most `PARENT_ACK_TIMEOUT`; `false` when it never answers or drops the sender.
|
||||||
|
pub(super) async fn parent_ack(what: &'static str, ack: oneshot::Receiver<()>) -> bool {
|
||||||
|
match tokio::time::timeout(PARENT_ACK_TIMEOUT, ack).await {
|
||||||
|
Ok(Ok(())) => true,
|
||||||
|
Ok(Err(_)) => false,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(ack = what, "parent did not ack in time; proceeding to teardown");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub(super) fn task_model_override_error(
|
pub(super) fn task_model_override_error(
|
||||||
requested: Option<&str>,
|
requested: Option<&str>,
|
||||||
provenance: ModelOverrideProvenance,
|
provenance: ModelOverrideProvenance,
|
||||||
@@ -895,13 +988,22 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let agent_mcp_servers: Vec<_> = if is_plugin_agent {
|
let agent_mcp_servers: Vec<_> = if definition.mcp_servers.is_empty() {
|
||||||
if !definition.mcp_servers.is_empty() {
|
vec![]
|
||||||
tracing::warn!(
|
} else if is_plugin_agent {
|
||||||
agent = % definition.name, plugin = ? definition.plugin_name,
|
tracing::warn!(
|
||||||
"ignoring mcpServers on plugin agent (not supported for security)"
|
agent = % definition.name, plugin = ? definition.plugin_name,
|
||||||
);
|
"ignoring mcpServers on plugin agent (not supported for security)"
|
||||||
}
|
);
|
||||||
|
vec![]
|
||||||
|
} else if !crate::agent::folder_trust::agent_inline_hooks_allowed(definition.scope, || {
|
||||||
|
crate::agent::folder_trust::project_scope_allowed(&ctx.parent_cwd)
|
||||||
|
}) {
|
||||||
|
// An untrusted repo's inline stdio MCP command would run like an inline hook.
|
||||||
|
tracing::warn!(
|
||||||
|
agent = % definition.name,
|
||||||
|
"ignoring mcpServers on untrusted project agent (folder not trusted; re-run with --trust)"
|
||||||
|
);
|
||||||
vec![]
|
vec![]
|
||||||
} else {
|
} else {
|
||||||
definition
|
definition
|
||||||
@@ -1333,7 +1435,12 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
let duration_ms = start.elapsed().as_millis() as u64;
|
let duration_ms = start.elapsed().as_millis() as u64;
|
||||||
let mut result = match wait_outcome {
|
let mut result = match wait_outcome {
|
||||||
SubagentWaitOutcome::Cancelled => {
|
SubagentWaitOutcome::Cancelled => {
|
||||||
let (tool_calls, turns) = signals_snapshot_counts(&child_handle).await;
|
let (tool_calls, turns) = child_actor_query(
|
||||||
|
"signals_snapshot_counts",
|
||||||
|
signals_snapshot_counts(&child_handle),
|
||||||
|
(0, 0),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
SubagentResult {
|
SubagentResult {
|
||||||
success: false,
|
success: false,
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
@@ -1360,14 +1467,28 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
) => (snapshot.current.tool_call_count, snapshot.current.turn_count),
|
) => (snapshot.current.tool_call_count, snapshot.current.turn_count),
|
||||||
_ => signals_snapshot_counts(&child_handle).await,
|
_ => {
|
||||||
|
child_actor_query(
|
||||||
|
"signals_snapshot_counts",
|
||||||
|
signals_snapshot_counts(&child_handle),
|
||||||
|
(0, 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let final_text = child_handle
|
let final_text = child_actor_query(
|
||||||
.chat_state_handle
|
"get_last_assistant_text",
|
||||||
.get_last_assistant_text()
|
child_handle.chat_state_handle.get_last_assistant_text(),
|
||||||
.await
|
None,
|
||||||
.unwrap_or_default();
|
)
|
||||||
let result_tokens = child_handle.chat_state_handle.get_total_tokens().await;
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let result_tokens = child_actor_query(
|
||||||
|
"get_total_tokens",
|
||||||
|
child_handle.chat_state_handle.get_total_tokens(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
match *turn_result {
|
match *turn_result {
|
||||||
Ok(
|
Ok(
|
||||||
Ok(
|
Ok(
|
||||||
@@ -1527,14 +1648,21 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
let final_status = result.status().to_string();
|
let final_status = result.status().to_string();
|
||||||
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
|
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
|
||||||
let telemetry_tokens = if result.tool_calls > 0 || result.success {
|
let telemetry_tokens = if result.tool_calls > 0 || result.success {
|
||||||
child_handle.chat_state_handle.get_total_tokens().await
|
child_actor_query(
|
||||||
|
"get_total_tokens",
|
||||||
|
child_handle.chat_state_handle.get_total_tokens(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_handle
|
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_actor_query(
|
||||||
.chat_state_handle
|
"try_get_session_usage",
|
||||||
.try_get_session_usage()
|
child_handle.chat_state_handle.try_get_session_usage(),
|
||||||
.await
|
Err(()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
|
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
|
||||||
Err(()) => (None, true),
|
Err(()) => (None, true),
|
||||||
@@ -1553,7 +1681,7 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
respond_to,
|
respond_to,
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
Ok(()) => ack.await.is_ok(),
|
Ok(()) => parent_ack("RecordSubagentUsage", ack).await,
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1570,53 +1698,71 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
.parent_prompt_id
|
.parent_prompt_id
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id));
|
.or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id));
|
||||||
if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
|
// A slow parent may apply both the fold and this mark; both are idempotent.
|
||||||
|
let marked_by_parent = if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
|
||||||
let (respond_to, ack) = tokio::sync::oneshot::channel();
|
let (respond_to, ack) = tokio::sync::oneshot::channel();
|
||||||
if cmd_tx
|
cmd_tx
|
||||||
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
|
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
|
||||||
parent_prompt_id: sticky_prompt,
|
parent_prompt_id: sticky_prompt.clone(),
|
||||||
respond_to,
|
respond_to,
|
||||||
})
|
})
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
&& parent_ack("MarkSubagentUsageNotApplied", ack).await
|
||||||
let _ = ack.await;
|
} else {
|
||||||
}
|
false
|
||||||
} else if let Some(ref pid) = sticky_prompt {
|
};
|
||||||
|
if !marked_by_parent && let Some(ref pid) = sticky_prompt {
|
||||||
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
|
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
|
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
|
||||||
(Some(parent_tb), Some(parent_notif_handle)) => {
|
(Some(parent_tb), Some(parent_notif_handle)) => {
|
||||||
if !request.surface_completion {
|
if !request.surface_completion {
|
||||||
let reparented_task_ids: Vec<String> = parent_tb
|
match tokio::time::timeout(PARENT_ACK_TIMEOUT, parent_tb.list_tasks()).await {
|
||||||
.list_tasks()
|
Ok(tasks) => {
|
||||||
.await
|
let reparented_task_ids: Vec<String> = tasks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|t| {
|
.filter(|t| {
|
||||||
!t.completed
|
!t.completed
|
||||||
&& t.owner_session_id.as_deref()
|
&& t.owner_session_id.as_deref()
|
||||||
== Some(&*child_session_id.0)
|
== Some(&*child_session_id.0)
|
||||||
})
|
})
|
||||||
.map(|t| t.task_id)
|
.map(|t| t.task_id)
|
||||||
.collect();
|
.collect();
|
||||||
if !reparented_task_ids.is_empty()
|
if !reparented_task_ids.is_empty()
|
||||||
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
|
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
|
||||||
{
|
{
|
||||||
let _ = cmd_tx
|
let _ = cmd_tx.send(SessionCommand::RecordGoalTurnTaskIds {
|
||||||
.send(SessionCommand::RecordGoalTurnTaskIds {
|
task_ids: reparented_task_ids,
|
||||||
task_ids: reparented_task_ids,
|
});
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
Err(_) => tracing::warn!(
|
||||||
|
child_session_id = % child_session_id.0,
|
||||||
|
parent_session_id = % ctx.parent_session_id,
|
||||||
|
"list_tasks not answered in time; skipping goal-turn task-id recording"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let parent_backend_weak = std::sync::Arc::downgrade(parent_tb);
|
let parent_backend_weak = std::sync::Arc::downgrade(parent_tb);
|
||||||
parent_tb
|
if tokio::time::timeout(
|
||||||
.reparent_notifications(
|
PARENT_ACK_TIMEOUT,
|
||||||
|
parent_tb.reparent_notifications(
|
||||||
&child_session_id.0,
|
&child_session_id.0,
|
||||||
&ctx.parent_session_id,
|
&ctx.parent_session_id,
|
||||||
parent_notif_handle.clone(),
|
parent_notif_handle.clone(),
|
||||||
parent_backend_weak,
|
parent_backend_weak,
|
||||||
)
|
),
|
||||||
.await;
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
child_session_id = % child_session_id.0,
|
||||||
|
parent_session_id = % ctx.parent_session_id,
|
||||||
|
"reparent_notifications not acked in time; proceeding to child shutdown"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(Some(_), None) | (None, Some(_)) => {
|
(Some(_), None) | (None, Some(_)) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -1637,55 +1783,17 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
if snapshot_dispose_enabled {
|
if snapshot_dispose_enabled {
|
||||||
let ref_name = format!("refs/kigi/subagents/{}", request.id);
|
let ref_name = format!("refs/kigi/subagents/{}", request.id);
|
||||||
let source_repo = resolve_subagent_source_repo(&ctx);
|
let source_repo = resolve_subagent_source_repo(&ctx);
|
||||||
match crate::session::worktree::snapshot_subagent_worktree(
|
let (snapshot_ref, removed) = dispose_subagent_worktree(
|
||||||
wt_path,
|
wt_path,
|
||||||
&source_repo,
|
&source_repo,
|
||||||
&ref_name,
|
&ref_name,
|
||||||
)
|
&subagent_meta_dir,
|
||||||
.await
|
&final_status,
|
||||||
{
|
&request.id,
|
||||||
Ok(snapshot_ref) => {
|
)
|
||||||
let persisted = update_subagent_meta_snapshot_ref(
|
.await;
|
||||||
&subagent_meta_dir,
|
disposed_snapshot_ref = snapshot_ref;
|
||||||
&snapshot_ref,
|
worktree_removed = removed;
|
||||||
&final_status,
|
|
||||||
);
|
|
||||||
if persisted {
|
|
||||||
disposed_snapshot_ref = Some(snapshot_ref);
|
|
||||||
match crate::session::worktree::remove_subagent_worktree(wt_path)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => {
|
|
||||||
worktree_removed = true;
|
|
||||||
tracing::info!(
|
|
||||||
subagent_id = % request.id, worktree_path = % wt_path
|
|
||||||
.display(), "snapshotted and removed subagent worktree"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
subagent_id = % request.id, worktree_path = % wt_path
|
|
||||||
.display(), error = % e,
|
|
||||||
"snapshotted subagent worktree but removal failed; ref persisted for resume"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
subagent_id = % request.id, worktree_path = % wt_path
|
|
||||||
.display(),
|
|
||||||
"snapshot_ref not persisted; preserving worktree for resume"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
|
||||||
error = % e,
|
|
||||||
"Failed to snapshot subagent worktree; preserving for review"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
||||||
|
|||||||
@@ -3298,3 +3298,27 @@ fn spawn_test_parent_chat_state(model_slug: &str) -> kigi_chat_state::ChatStateH
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
mod rest;
|
mod rest;
|
||||||
|
|
||||||
|
/// A blocked child actor must not park teardown: the read degrades to the fallback in bounded time.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn child_actor_query_is_bounded_when_the_actor_never_answers() {
|
||||||
|
use super::handle_request::{CHILD_ACTOR_ACK_TIMEOUT, child_actor_query};
|
||||||
|
let tokens = tokio::time::timeout(
|
||||||
|
20 * CHILD_ACTOR_ACK_TIMEOUT,
|
||||||
|
child_actor_query("test_query", std::future::pending::<u64>(), 7),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("child-actor queries must complete in bounded time");
|
||||||
|
assert_eq!(tokens, 7, "a starved query must degrade to the fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parent that never services `cmd_rx` must not park a completed child.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn parent_ack_is_bounded_when_the_parent_never_answers() {
|
||||||
|
use super::handle_request::{PARENT_ACK_TIMEOUT, parent_ack};
|
||||||
|
let (_never_answered, ack) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
let acked = tokio::time::timeout(20 * PARENT_ACK_TIMEOUT, parent_ack("test_ack", ack))
|
||||||
|
.await
|
||||||
|
.expect("parent acks must complete in bounded time");
|
||||||
|
assert!(!acked, "an unanswered ack must report a miss");
|
||||||
|
}
|
||||||
@@ -1034,16 +1034,27 @@ pub fn apply_sandbox(
|
|||||||
.and_then(|p| dunce::canonicalize(p).ok())
|
.and_then(|p| dunce::canonicalize(p).ok())
|
||||||
.or_else(|| std::env::current_dir().ok())
|
.or_else(|| std::env::current_dir().ok())
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||||
|
if kigi_sandbox::restricts_home_writes(&sandbox_profile, &workspace) {
|
||||||
|
// The profile leaves ~/.kigi read-only outside its state dirs: mint
|
||||||
|
// what first use would otherwise write after enforcement.
|
||||||
|
if let Err(e) = crate::auth::device::device_id() {
|
||||||
|
tracing::warn!(error = %e, "sandbox: device id not created before enforcement");
|
||||||
|
}
|
||||||
|
kigi_tools::implementations::kigi::grep::ripgrep::rg_path();
|
||||||
|
crate::builtin::extract_bundled_files(&crate::util::kigi_home::kigi_home());
|
||||||
|
}
|
||||||
|
// The devbox `/data` write-deny is a bwrap mount; Landlock would grant the workspace under `/data`.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let requires_read_deny = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace);
|
let bwrap_required = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace)
|
||||||
|
|| kigi_sandbox::requires_data_write_deny(&sandbox_profile, &workspace);
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
let refuse_unprotected = |detail: &str| {
|
let refuse_unprotected = |detail: &str| {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"error: this sandbox could not enforce its read-deny set on Linux \
|
"error: this sandbox could not enforce its read-deny set or the devbox \
|
||||||
(bubblewrap missing/unusable, or a deny glob exceeded its expansion \
|
`/data` write-deny on Linux (bubblewrap missing/unusable, or a deny glob \
|
||||||
limit — see any message above). Install bubblewrap with \
|
exceeded its expansion limit — see any message above). Install bubblewrap \
|
||||||
`apt install -y bubblewrap` if needed. Refusing to start with denied \
|
with `apt install -y bubblewrap` if needed. Refusing to start with denied \
|
||||||
paths unprotected.{detail}"
|
paths unprotected.{detail}"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1051,7 +1062,7 @@ pub fn apply_sandbox(
|
|||||||
Some(mut cmd) => {
|
Some(mut cmd) => {
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
let err = cmd.exec();
|
let err = cmd.exec();
|
||||||
if requires_read_deny {
|
if bwrap_required {
|
||||||
refuse_unprotected(&format!(" (bwrap exec failed: {err})"));
|
refuse_unprotected(&format!(" (bwrap exec failed: {err})"));
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1072,7 @@ pub fn apply_sandbox(
|
|||||||
Install bubblewrap: apt install -y bubblewrap"
|
Install bubblewrap: apt install -y bubblewrap"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None if requires_read_deny && !kigi_sandbox::is_inside_bwrap() => {
|
None if bwrap_required && !kigi_sandbox::is_inside_bwrap() => {
|
||||||
refuse_unprotected("");
|
refuse_unprotected("");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ async fn handle_compact(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|||||||
respond_to: tx,
|
respond_to: tx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The session's own ACP error goes through unchanged: a Debug wrapper would escape its detail.
|
||||||
rx.await
|
rx.await
|
||||||
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
|
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))??;
|
||||||
.map_err(|e| acp::Error::internal_error().data(format!("Internal error: {:?}", e)))?;
|
|
||||||
to_raw_response(&CompactConversationResponse {})
|
to_raw_response(&CompactConversationResponse {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -691,6 +691,11 @@ pub enum SessionUpdate {
|
|||||||
/// does not support reasoning effort or no effort override was applied.
|
/// does not support reasoning effort or no effort override was applied.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
reasoning_effort: Option<String>,
|
reasoning_effort: Option<String>,
|
||||||
|
/// The session's effective context window after the switch, so a
|
||||||
|
/// follower repaints its header without waiting for the next turn.
|
||||||
|
/// `None` from an agent that predates the field.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
context_window: Option<u64>,
|
||||||
},
|
},
|
||||||
/// Streaming chunk of a tool call's arguments.
|
/// Streaming chunk of a tool call's arguments.
|
||||||
///
|
///
|
||||||
@@ -1970,15 +1975,18 @@ mod tests {
|
|||||||
let with_effort = SessionUpdate::ModelChanged {
|
let with_effort = SessionUpdate::ModelChanged {
|
||||||
model_id: "kigi-4".into(),
|
model_id: "kigi-4".into(),
|
||||||
reasoning_effort: Some("high".into()),
|
reasoning_effort: Some("high".into()),
|
||||||
|
context_window: Some(262_144),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&with_effort).unwrap();
|
let json = serde_json::to_value(&with_effort).unwrap();
|
||||||
assert_eq!(json["sessionUpdate"], "model_changed");
|
assert_eq!(json["sessionUpdate"], "model_changed");
|
||||||
assert_eq!(json["model_id"], "kigi-4");
|
assert_eq!(json["model_id"], "kigi-4");
|
||||||
assert_eq!(json["reasoning_effort"], "high");
|
assert_eq!(json["reasoning_effort"], "high");
|
||||||
|
assert_eq!(json["context_window"], 262_144);
|
||||||
|
|
||||||
let without_effort = SessionUpdate::ModelChanged {
|
let without_effort = SessionUpdate::ModelChanged {
|
||||||
model_id: "kigi-3".into(),
|
model_id: "kigi-3".into(),
|
||||||
reasoning_effort: None,
|
reasoning_effort: None,
|
||||||
|
context_window: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&without_effort).unwrap();
|
let json = serde_json::to_value(&without_effort).unwrap();
|
||||||
assert_eq!(json["sessionUpdate"], "model_changed");
|
assert_eq!(json["sessionUpdate"], "model_changed");
|
||||||
@@ -1988,6 +1996,7 @@ mod tests {
|
|||||||
"reasoning_effort: None must be skipped on the wire so old pagers \
|
"reasoning_effort: None must be skipped on the wire so old pagers \
|
||||||
and third-party ACP clients see a smaller, no-extra-keys payload"
|
and third-party ACP clients see a smaller, no-extra-keys payload"
|
||||||
);
|
);
|
||||||
|
assert!(json.get("context_window").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `ModelChanged` round-trips through JSON: a follower client deserializes
|
/// `ModelChanged` round-trips through JSON: a follower client deserializes
|
||||||
@@ -2000,6 +2009,7 @@ mod tests {
|
|||||||
let original = SessionUpdate::ModelChanged {
|
let original = SessionUpdate::ModelChanged {
|
||||||
model_id: "kigi-4".into(),
|
model_id: "kigi-4".into(),
|
||||||
reasoning_effort: Some("medium".into()),
|
reasoning_effort: Some("medium".into()),
|
||||||
|
context_window: Some(1_000_000),
|
||||||
};
|
};
|
||||||
let json_str = serde_json::to_string(&original).unwrap();
|
let json_str = serde_json::to_string(&original).unwrap();
|
||||||
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
|
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
|
||||||
@@ -2021,6 +2031,7 @@ mod tests {
|
|||||||
update: SessionUpdate::ModelChanged {
|
update: SessionUpdate::ModelChanged {
|
||||||
model_id: "kigi-4".into(),
|
model_id: "kigi-4".into(),
|
||||||
reasoning_effort: None,
|
reasoning_effort: None,
|
||||||
|
context_window: None,
|
||||||
},
|
},
|
||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,10 +52,16 @@ pub fn default_lock_path_in(root: &Path) -> PathBuf {
|
|||||||
root.join("leader.lock")
|
root.join("leader.lock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runtime files live under `~/.kigi/run`: the one home subtree a strict
|
||||||
|
/// sandbox leaves writable for them.
|
||||||
|
pub(super) fn runtime_root() -> PathBuf {
|
||||||
|
kigi_home().join("run")
|
||||||
|
}
|
||||||
|
|
||||||
/// Effective leader lock path: the [`LEADER_SOCKET_ENV`] override's sibling
|
/// Effective leader lock path: the [`LEADER_SOCKET_ENV`] override's sibling
|
||||||
/// `.lock` when set, else the default under kigi home.
|
/// `.lock` when set, else the default under `~/.kigi/run`.
|
||||||
pub fn default_lock_path() -> PathBuf {
|
pub fn default_lock_path() -> PathBuf {
|
||||||
resolve_lock_path(leader_socket_override(), &kigi_home())
|
resolve_lock_path(leader_socket_override(), &runtime_root())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_socket_path_in(root: &Path) -> PathBuf {
|
pub fn default_socket_path_in(root: &Path) -> PathBuf {
|
||||||
@@ -63,9 +69,9 @@ pub fn default_socket_path_in(root: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Effective leader socket path: the [`LEADER_SOCKET_ENV`] override when set,
|
/// Effective leader socket path: the [`LEADER_SOCKET_ENV`] override when set,
|
||||||
/// else the default under kigi home.
|
/// else the default under `~/.kigi/run`.
|
||||||
pub fn default_socket_path() -> PathBuf {
|
pub fn default_socket_path() -> PathBuf {
|
||||||
resolve_socket_path(leader_socket_override(), &kigi_home())
|
resolve_socket_path(leader_socket_override(), &runtime_root())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The instance suffix encoded in a lock/socket file-name pair
|
/// The instance suffix encoded in a lock/socket file-name pair
|
||||||
@@ -145,6 +151,9 @@ impl LeaderLock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_lock_file(&self) -> Result<File, LockError> {
|
fn open_lock_file(&self) -> Result<File, LockError> {
|
||||||
|
if let Some(parent) = self.lock_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
Ok(OpenOptions::new()
|
Ok(OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
//! │ │ - Tracks session ownership for routing ││
|
//! │ │ - Tracks session ownership for routing ││
|
||||||
//! │ └────────────────────────┬────────────────────────────────┘│
|
//! │ └────────────────────────┬────────────────────────────────┘│
|
||||||
//! └───────────────────────────┼──────────────────────────────────┘
|
//! └───────────────────────────┼──────────────────────────────────┘
|
||||||
//! │ IPC (Unix socket at ~/.kigi/leader.sock)
|
//! │ IPC (Unix socket at ~/.kigi/run/leader.sock)
|
||||||
//! ┌───────────────────┼───────────────────┐
|
//! ┌───────────────────┼───────────────────┐
|
||||||
//! ▼ ▼ ▼
|
//! ▼ ▼ ▼
|
||||||
//! ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
//! ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||||
@@ -481,7 +481,10 @@ async fn discover_leaders_in(root: &Path) -> Vec<LeaderDescriptor> {
|
|||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
pub async fn discover_leaders() -> Vec<LeaderDescriptor> {
|
pub async fn discover_leaders() -> Vec<LeaderDescriptor> {
|
||||||
discover_leaders_in(&crate::util::kigi_home::kigi_home()).await
|
let mut entries = discover_leaders_in(&lock::runtime_root()).await;
|
||||||
|
// Pre-0.1.15 leaders and socket overrides keep their files at the home root.
|
||||||
|
entries.extend(discover_leaders_in(&crate::util::kigi_home::kigi_home()).await);
|
||||||
|
entries
|
||||||
}
|
}
|
||||||
/// (pid, leader_binary_version) of socket-verified (Reachable) leaders; a
|
/// (pid, leader_binary_version) of socket-verified (Reachable) leaders; a
|
||||||
/// stale-lock-only descriptor is skipped (its `pid_from_lock` may be recycled).
|
/// stale-lock-only descriptor is skipped (its `pid_from_lock` may be recycled).
|
||||||
@@ -610,13 +613,28 @@ fn resolve_matching_descriptors(
|
|||||||
fn resolve_target_from_descriptors(
|
fn resolve_target_from_descriptors(
|
||||||
target: LeaderTarget,
|
target: LeaderTarget,
|
||||||
leaders: Vec<LeaderDescriptor>,
|
leaders: Vec<LeaderDescriptor>,
|
||||||
|
current_default_socket: &Path,
|
||||||
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
||||||
match target {
|
match target {
|
||||||
LeaderTarget::Default => {
|
LeaderTarget::Default => {
|
||||||
let matching: Vec<_> = leaders
|
let defaults: Vec<_> = leaders
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|descriptor| descriptor.is_default)
|
.filter(|descriptor| descriptor.is_default)
|
||||||
.collect();
|
.collect();
|
||||||
|
// An upgrade can leave a pre-0.1.15 default at the home root; the
|
||||||
|
// current default socket wins while it exists.
|
||||||
|
let current: Vec<_> = defaults
|
||||||
|
.iter()
|
||||||
|
.filter(|descriptor| {
|
||||||
|
descriptor.socket_path.as_deref() == Some(current_default_socket)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let matching = if current.is_empty() {
|
||||||
|
defaults
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
};
|
||||||
resolve_matching_descriptors("default", matching)
|
resolve_matching_descriptors("default", matching)
|
||||||
}
|
}
|
||||||
LeaderTarget::Socket(socket_path) => {
|
LeaderTarget::Socket(socket_path) => {
|
||||||
@@ -685,7 +703,7 @@ pub async fn resolve_leader_target(
|
|||||||
target: LeaderTarget,
|
target: LeaderTarget,
|
||||||
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
||||||
let leaders = discover_leaders().await;
|
let leaders = discover_leaders().await;
|
||||||
resolve_target_from_descriptors(target, leaders)
|
resolve_target_from_descriptors(target, leaders, &lock::default_socket_path())
|
||||||
}
|
}
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConnectionError {
|
pub enum ConnectionError {
|
||||||
@@ -1315,7 +1333,12 @@ fn spawn_leader_subprocess() -> Result<u32, ConnectionError> {
|
|||||||
}
|
}
|
||||||
cmd.stdin(std::process::Stdio::null())
|
cmd.stdin(std::process::Stdio::null())
|
||||||
.stdout(std::process::Stdio::null());
|
.stdout(std::process::Stdio::null());
|
||||||
let log_path = crate::util::kigi_home::kigi_home().join("leader.log");
|
let log_path = crate::util::kigi_home::kigi_home()
|
||||||
|
.join("logs")
|
||||||
|
.join("leader.log");
|
||||||
|
if let Some(parent) = log_path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
match std::fs::File::create(&log_path) {
|
match std::fs::File::create(&log_path) {
|
||||||
Ok(log_file) => {
|
Ok(log_file) => {
|
||||||
info!("Leader stderr → log file");
|
info!("Leader stderr → log file");
|
||||||
@@ -1444,6 +1467,46 @@ mod tests {
|
|||||||
vec![(222, "0.2.52".to_string())]
|
vec![(222, "0.2.52".to_string())]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
fn reachable_default(socket: &str) -> LeaderDescriptor {
|
||||||
|
let socket_path = PathBuf::from(socket);
|
||||||
|
LeaderDescriptor {
|
||||||
|
pid_from_lock: Some(1),
|
||||||
|
lock_path: Some(socket_path.with_extension("lock")),
|
||||||
|
socket_path: Some(socket_path.clone()),
|
||||||
|
socket_suffix: String::new(),
|
||||||
|
classification: LeaderDiscoveryState::Reachable,
|
||||||
|
is_default: true,
|
||||||
|
live_info: Some(LiveLeaderInfo {
|
||||||
|
pid: 1,
|
||||||
|
socket_path,
|
||||||
|
lock_path: PathBuf::new(),
|
||||||
|
socket_suffix: String::new(),
|
||||||
|
leader_protocol_version: 1,
|
||||||
|
leader_binary_version: "0.1.15".to_string(),
|
||||||
|
}),
|
||||||
|
target_error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn default_target_prefers_the_current_socket_over_a_legacy_default() {
|
||||||
|
let legacy = reachable_default("/h/.kigi/leader.sock");
|
||||||
|
let current = reachable_default("/h/.kigi/run/leader.sock");
|
||||||
|
let current_socket = PathBuf::from("/h/.kigi/run/leader.sock");
|
||||||
|
let picked = resolve_target_from_descriptors(
|
||||||
|
LeaderTarget::Default,
|
||||||
|
vec![current.clone(), legacy.clone()],
|
||||||
|
¤t_socket,
|
||||||
|
)
|
||||||
|
.expect("current default resolves");
|
||||||
|
assert_eq!(picked.descriptor.socket_path, current.socket_path);
|
||||||
|
let fallback = resolve_target_from_descriptors(
|
||||||
|
LeaderTarget::Default,
|
||||||
|
vec![legacy.clone()],
|
||||||
|
¤t_socket,
|
||||||
|
)
|
||||||
|
.expect("legacy default still resolves alone");
|
||||||
|
assert_eq!(fallback.descriptor.socket_path, legacy.socket_path);
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn leader_is_older_than_directional() {
|
fn leader_is_older_than_directional() {
|
||||||
assert!(leader_is_older_than("0.1.0", "0.2.0"));
|
assert!(leader_is_older_than("0.1.0", "0.2.0"));
|
||||||
|
|||||||
@@ -1146,6 +1146,9 @@ pub async fn run_leader_server(
|
|||||||
control_state: LeaderServerControlState,
|
control_state: LeaderServerControlState,
|
||||||
) -> Result<(), ServerError> {
|
) -> Result<(), ServerError> {
|
||||||
let _ = std::fs::remove_file(&socket_path);
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
|
if let Some(parent) = socket_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
let shutdown_reason_rx = shutdown_tx.subscribe();
|
let shutdown_reason_rx = shutdown_tx.subscribe();
|
||||||
let listener = LeaderListener::bind(&socket_path)?;
|
let listener = LeaderListener::bind(&socket_path)?;
|
||||||
info!("Leader server listening");
|
info!("Leader server listening");
|
||||||
|
|||||||
@@ -171,6 +171,70 @@ pub fn error_detail_from_data(data: &serde_json::Value) -> Option<String> {
|
|||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The human-readable text of an ACP error: its `data` detail when present (Display would JSON-escape it), else the message.
|
||||||
|
pub fn acp_error_message(err: &acp::Error) -> String {
|
||||||
|
err.data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(error_detail_from_data)
|
||||||
|
.unwrap_or_else(|| err.message.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First line of a compaction error without internal `compact failed:` prefixes, capped for a banner; diagnostic lines (request URLs) are dropped.
|
||||||
|
pub fn user_facing_compact_error(raw: &str) -> String {
|
||||||
|
const INTERNAL_PREFIX: &str = "compact failed:";
|
||||||
|
const MAX_CHARS: usize = 200;
|
||||||
|
// Redact before line selection: a line break inside a URL must not hide its `@`.
|
||||||
|
let raw = redact_urls_in_text(raw);
|
||||||
|
let mut rest = raw.trim();
|
||||||
|
while rest
|
||||||
|
.get(..INTERNAL_PREFIX.len())
|
||||||
|
.is_some_and(|head| head.eq_ignore_ascii_case(INTERNAL_PREFIX))
|
||||||
|
{
|
||||||
|
rest = rest[INTERNAL_PREFIX.len()..].trim_start();
|
||||||
|
}
|
||||||
|
rest.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.take(MAX_CHARS)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every canonical `scheme://` URL in `text` loses userinfo, sensitive query values and fragment; one that fails to parse is not echoed.
|
||||||
|
fn redact_urls_in_text(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut rest = text;
|
||||||
|
while let Some(sep) = rest.find("://") {
|
||||||
|
// The delimiter before the scheme may be multi-byte (`“`, `:`): step past its UTF-8 length.
|
||||||
|
let scheme_start = rest[..sep]
|
||||||
|
.char_indices()
|
||||||
|
.rev()
|
||||||
|
.find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
|
||||||
|
.map_or(0, |(at, c)| at + c.len_utf8());
|
||||||
|
out.push_str(&rest[..scheme_start]);
|
||||||
|
// The authority may hold whitespace (a configured password); the token ends at whitespace after it.
|
||||||
|
let authority = &rest[sep + 3..];
|
||||||
|
let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
|
||||||
|
let token_end = authority[authority_end..]
|
||||||
|
.find(char::is_whitespace)
|
||||||
|
.map_or(rest.len(), |at| sep + 3 + authority_end + at);
|
||||||
|
let token = &rest[scheme_start..token_end];
|
||||||
|
let url_text = token.trim_end_matches([')', ']', '}', ',', '.', ';', '\'', '"', '>']);
|
||||||
|
match url::Url::parse(url_text) {
|
||||||
|
Ok(mut url) => {
|
||||||
|
kigi_secrets::redact_url(&mut url);
|
||||||
|
out.push_str(url.as_str());
|
||||||
|
}
|
||||||
|
Err(_) => out.push_str("<unparseable url>"),
|
||||||
|
}
|
||||||
|
out.push_str(&token[url_text.len()..]);
|
||||||
|
rest = &rest[token_end..];
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn http_status_from_error(err: &acp::Error) -> Option<u16> {
|
pub fn http_status_from_error(err: &acp::Error) -> Option<u16> {
|
||||||
err.data
|
err.data
|
||||||
.as_ref()?
|
.as_ref()?
|
||||||
@@ -254,6 +318,77 @@ pub fn prompt_complete_fields(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn compact_error_text_keeps_the_first_decoded_line_only() {
|
||||||
|
let err = super::acp::Error::internal_error().data(
|
||||||
|
"compact failed: API error (status 500): overloaded\n\nRequest URL: http://u:TEST_PASSWORD@127.0.0.1/v1",
|
||||||
|
);
|
||||||
|
let detail = super::user_facing_compact_error(&super::acp_error_message(&err));
|
||||||
|
assert_eq!(detail, "API error (status 500): overloaded");
|
||||||
|
assert!(!detail.contains("TEST_PASSWORD"));
|
||||||
|
assert_eq!(
|
||||||
|
super::user_facing_compact_error(&"x".repeat(500))
|
||||||
|
.chars()
|
||||||
|
.count(),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::acp_error_message(&super::acp::Error::internal_error()),
|
||||||
|
"Internal error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sampler 401 names the endpoint on the retained line; its userinfo must not survive.
|
||||||
|
#[test]
|
||||||
|
fn compact_error_text_redacts_url_credentials_on_the_first_line() {
|
||||||
|
for password in [
|
||||||
|
"TEST_PASSWORD",
|
||||||
|
"TEST PASSWORD",
|
||||||
|
"TEST%20PASSWORD",
|
||||||
|
"TEST_PASSWORD\n",
|
||||||
|
"TEST_PASSWORD\r\n",
|
||||||
|
] {
|
||||||
|
let raw = format!(
|
||||||
|
"compact failed: Unauthorized (401) from http://u:{password}@127.0.0.1:33263/401/v1/chat/completions: bad auth"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::user_facing_compact_error(&raw),
|
||||||
|
"Unauthorized (401) from http://127.0.0.1:33263/401/v1/chat/completions: bad auth",
|
||||||
|
"{password:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
super::redact_urls_in_text("see https://a:b@h.example/x and http://h2/"),
|
||||||
|
"see https://h.example/x and http://h2/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A credential carried as a query value survives no failure shape: 401 message or transport error.
|
||||||
|
#[test]
|
||||||
|
fn compact_error_text_redacts_query_credentials_in_every_failure_shape() {
|
||||||
|
let unauthorized = "compact failed: Unauthorized (401) from http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=/v1/chat/completions: bad auth";
|
||||||
|
let transport = "compact failed: request error: error sending request for url (http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=/500/v1/chat/completions)";
|
||||||
|
for raw in [unauthorized, transport] {
|
||||||
|
let detail = super::user_facing_compact_error(raw);
|
||||||
|
assert!(!detail.contains("TEST_QUERY_TOKEN"), "{detail}");
|
||||||
|
assert!(detail.contains("route="), "{detail}");
|
||||||
|
}
|
||||||
|
assert!(super::user_facing_compact_error("see http://:://").contains("<unparseable url>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A multi-byte delimiter right before the URL must not break the scan.
|
||||||
|
#[test]
|
||||||
|
fn compact_error_text_handles_unicode_before_a_url() {
|
||||||
|
for raw in [
|
||||||
|
"compact failed: Service unavailable. See “https://u:TEST_PASSWORD@status.example/”.",
|
||||||
|
"compact failed: 请参阅:https://u:TEST_PASSWORD@status.example/",
|
||||||
|
] {
|
||||||
|
let detail = super::user_facing_compact_error(raw);
|
||||||
|
assert!(!detail.contains("TEST_PASSWORD"), "{detail}");
|
||||||
|
assert!(detail.contains("https://status.example/"), "{detail}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
|||||||
@@ -1518,6 +1518,9 @@ mod load_user_prompts_tests;
|
|||||||
#[path = "acp_session_tests/memory_config_tests.rs"]
|
#[path = "acp_session_tests/memory_config_tests.rs"]
|
||||||
mod memory_config_tests;
|
mod memory_config_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
#[path = "acp_session_tests/model_switch_window_tests.rs"]
|
||||||
|
mod model_switch_window_tests;
|
||||||
|
#[cfg(test)]
|
||||||
#[path = "acp_session_tests/parallel_dispatch_tests.rs"]
|
#[path = "acp_session_tests/parallel_dispatch_tests.rs"]
|
||||||
mod parallel_dispatch_tests;
|
mod parallel_dispatch_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ impl SessionActor {
|
|||||||
apply_prompt_override: bool,
|
apply_prompt_override: bool,
|
||||||
skip_prompt_rewrite: bool,
|
skip_prompt_rewrite: bool,
|
||||||
auto_compact_threshold_percent: u8,
|
auto_compact_threshold_percent: u8,
|
||||||
) -> Result<acp::ModelId, acp::Error> {
|
) -> Result<(acp::ModelId, std::num::NonZeroU64), acp::Error> {
|
||||||
let model_id = acp::ModelId::new(sampling_config.model.clone());
|
let model_id = acp::ModelId::new(sampling_config.model.clone());
|
||||||
// H4: record the picker's catalog KEY as this SESSION's own selection.
|
// H4: record the picker's catalog KEY as this SESSION's own selection.
|
||||||
// `sampling_config.model` is the ambiguous bare slug; the key is what
|
// `sampling_config.model` is the ambiguous bare slug; the key is what
|
||||||
@@ -120,7 +120,7 @@ impl SessionActor {
|
|||||||
agent_name: Some(agent_name),
|
agent_name: Some(agent_name),
|
||||||
reasoning_effort: Some(sampling_config.reasoning_effort),
|
reasoning_effort: Some(sampling_config.reasoning_effort),
|
||||||
});
|
});
|
||||||
Ok(model_id)
|
Ok((model_id, new_context_window))
|
||||||
}
|
}
|
||||||
/// Handle [`SessionCommand::RebuildAgentForDefinition`].
|
/// Handle [`SessionCommand::RebuildAgentForDefinition`].
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -548,17 +548,10 @@ impl SessionActor {
|
|||||||
|
|
||||||
if report.changed() && !dry_run {
|
if report.changed() && !dry_run {
|
||||||
// Flush barrier: success must mean the rewrite is on disk.
|
// Flush barrier: success must mean the rewrite is on disk.
|
||||||
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
|
if let Err(error) = self.persistence_barrier().await {
|
||||||
if self
|
anyhow::bail!(
|
||||||
.notifications
|
"history repaired in memory but the persistence flush failed: {error}"
|
||||||
.persistence_tx
|
);
|
||||||
.send(PersistenceMsg::FlushAndAck {
|
|
||||||
respond_to: flush_tx,
|
|
||||||
})
|
|
||||||
.is_err()
|
|
||||||
|| flush_rx.await.is_err()
|
|
||||||
{
|
|
||||||
anyhow::bail!("history repaired in memory but the persistence flush failed");
|
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
session_id = %self.session_info.id.0,
|
session_id = %self.session_info.id.0,
|
||||||
|
|||||||
@@ -957,6 +957,8 @@ impl SessionActor {
|
|||||||
Some(self.session_info.id.0.to_string()),
|
Some(self.session_info.id.0.to_string()),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
self.tool_context.cwd.clone(),
|
||||||
|
self.display_cwd.get().map(std::path::PathBuf::from),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -681,22 +681,14 @@ impl SessionActor {
|
|||||||
.await
|
.await
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
let (flush_tx, flush_rx) = oneshot::channel();
|
match self.persistence_barrier().await {
|
||||||
if self
|
Ok(()) => {
|
||||||
.notifications
|
let _ = ack.send(());
|
||||||
.persistence_tx
|
}
|
||||||
.send(PersistenceMsg::FlushAndAck {
|
Err(error) => tracing::error!(
|
||||||
respond_to: flush_tx,
|
session_id = % self.session_info.id.0, prompt_id = % prompt_id,
|
||||||
})
|
%error, "persist_ack flush barrier failed"
|
||||||
.is_ok()
|
),
|
||||||
&& flush_rx.await.is_ok()
|
|
||||||
{
|
|
||||||
let _ = ack.send(());
|
|
||||||
} else {
|
|
||||||
tracing::error!(
|
|
||||||
session_id = % self.session_info.id.0, prompt_id = %
|
|
||||||
prompt_id, "persist_ack flush barrier failed"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
|
|||||||
@@ -333,15 +333,24 @@ impl SessionActor {
|
|||||||
if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await {
|
if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await {
|
||||||
tracing::warn!(?e, "flush_replay_actor failed");
|
tracing::warn!(?e, "flush_replay_actor failed");
|
||||||
}
|
}
|
||||||
|
if let Err(error) = self.persistence_barrier().await {
|
||||||
|
tracing::error!(%error, "persistence flush failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// The `FlushAndAck` durability barrier: `Ok` only once every prior
|
||||||
|
/// persistence write is on stable media.
|
||||||
|
pub(super) async fn persistence_barrier(&self) -> std::io::Result<()> {
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
if self
|
if self
|
||||||
.notifications
|
.notifications
|
||||||
.persistence_tx
|
.persistence_tx
|
||||||
.send(PersistenceMsg::FlushAndAck { respond_to: tx })
|
.send(PersistenceMsg::FlushAndAck { respond_to: tx })
|
||||||
.is_ok()
|
.is_err()
|
||||||
{
|
{
|
||||||
let _ = rx.await;
|
return Err(std::io::Error::other("persistence actor gone"));
|
||||||
}
|
}
|
||||||
|
rx.await
|
||||||
|
.unwrap_or_else(|_| Err(std::io::Error::other("persistence actor gone")))
|
||||||
}
|
}
|
||||||
/// Extracts the update type name and relevant parameters for logging
|
/// Extracts the update type name and relevant parameters for logging
|
||||||
fn extract_update_info(
|
fn extract_update_info(
|
||||||
|
|||||||
+3
-3
@@ -435,7 +435,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
|
|||||||
respond_to: flush_tx,
|
respond_to: flush_tx,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
flush_rx.await.unwrap();
|
flush_rx.await.unwrap().unwrap();
|
||||||
let loaded = storage
|
let loaded = storage
|
||||||
.load_session_without_updates(&session_info)
|
.load_session_without_updates(&session_info)
|
||||||
.await
|
.await
|
||||||
@@ -757,7 +757,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
|||||||
respond_to: flush_tx,
|
respond_to: flush_tx,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
flush_rx.await.unwrap();
|
flush_rx.await.unwrap().unwrap();
|
||||||
let storage = crate::session::storage::JsonlStorageAdapter::with_explicit_session_dir(
|
let storage = crate::session::storage::JsonlStorageAdapter::with_explicit_session_dir(
|
||||||
session_dir.path().to_path_buf(),
|
session_dir.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
@@ -1310,7 +1310,7 @@ async fn actor_with_persistence_drain() -> std::sync::Arc<SessionActor> {
|
|||||||
tokio::task::spawn_local(async move {
|
tokio::task::spawn_local(async move {
|
||||||
while let Some(msg) = persistence_rx.recv().await {
|
while let Some(msg) = persistence_rx.recv().await {
|
||||||
if let PersistenceMsg::FlushAndAck { respond_to } = msg {
|
if let PersistenceMsg::FlushAndAck { respond_to } = msg {
|
||||||
let _ = respond_to.send(());
|
let _ = respond_to.send(Ok(()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//! `handle_set_session_model` reports the session's effective context
|
||||||
|
//! window so clients repaint their header without waiting for a turn.
|
||||||
|
use super::support::*;
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn set_session_model_returns_the_effective_context_window() {
|
||||||
|
let local = tokio::task::LocalSet::new();
|
||||||
|
local
|
||||||
|
.run_until(async {
|
||||||
|
let (actor, _rx) = build_actor().await;
|
||||||
|
let cfg = kigi_sampler::SamplerConfig {
|
||||||
|
model: "narrow".to_string(),
|
||||||
|
context_window: 128_000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (model_id, window) = actor
|
||||||
|
.handle_set_session_model(cfg, None, false, false, true, 85)
|
||||||
|
.await
|
||||||
|
.expect("model switch");
|
||||||
|
assert_eq!(model_id.0.as_ref(), "narrow");
|
||||||
|
assert_eq!(window.get(), 128_000);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
session.tool_context.cwd.clone(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// cargo is heuristic-allow when sampling fails; must not be Prompt-only
|
// cargo is heuristic-allow when sampling fails; must not be Prompt-only
|
||||||
@@ -99,6 +101,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
session.tool_context.cwd.clone(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -173,7 +173,9 @@ pub enum SessionCommand {
|
|||||||
/// `compaction.threshold_percent` (which is `Cell<u8>` so it can
|
/// `compaction.threshold_percent` (which is `Cell<u8>` so it can
|
||||||
/// update without `&mut self`).
|
/// update without `&mut self`).
|
||||||
auto_compact_threshold_percent: u8,
|
auto_compact_threshold_percent: u8,
|
||||||
responds_to: oneshot::Sender<Result<acp::ModelId, acp::Error>>,
|
/// Ok: the applied model id and the session's effective context
|
||||||
|
/// window, which clients repaint from without waiting for a turn.
|
||||||
|
responds_to: oneshot::Sender<Result<(acp::ModelId, std::num::NonZeroU64), acp::Error>>,
|
||||||
},
|
},
|
||||||
/// Zero-turn harness rebuild: build a brand-new `Agent` from the
|
/// Zero-turn harness rebuild: build a brand-new `Agent` from the
|
||||||
/// session's `AgentRebuildSpec` and the new `AgentDefinition`,
|
/// session's `AgentRebuildSpec` and the new `AgentDefinition`,
|
||||||
@@ -332,10 +334,10 @@ pub enum SessionCommand {
|
|||||||
CopyFile {
|
CopyFile {
|
||||||
respond_to: oneshot::Sender<anyhow::Result<crate::session::persistence::SessionStateCopy>>,
|
respond_to: oneshot::Sender<anyhow::Result<crate::session::persistence::SessionStateCopy>>,
|
||||||
},
|
},
|
||||||
/// Flush the replay buffer and persistence, then signal completion.
|
/// Flush the replay buffer and persistence, then report the durability
|
||||||
/// Used during reconnect to ensure all buffered content is persisted before replay.
|
/// barrier result. Used during reconnect so replay follows persisted content.
|
||||||
FlushComplete {
|
FlushComplete {
|
||||||
respond_to: oneshot::Sender<()>,
|
respond_to: oneshot::Sender<std::io::Result<()>>,
|
||||||
},
|
},
|
||||||
/// Update MCP servers for an existing session (used during reconnect or
|
/// Update MCP servers for an existing session (used during reconnect or
|
||||||
/// mid-session via the `kigi/session/update_mcp_servers` extension method).
|
/// mid-session via the `kigi/session/update_mcp_servers` extension method).
|
||||||
|
|||||||
@@ -689,6 +689,19 @@ impl SessionActor {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Retry guidance headline, then the user-facing detail on its own line.
|
||||||
|
fn failure_with_retry_guidance(err: &acp::Error) -> String {
|
||||||
|
const RETRY_GUIDANCE: &str =
|
||||||
|
"it'll retry on the next turn, or start a new session using /new.";
|
||||||
|
let detail = crate::sampling::error::user_facing_compact_error(
|
||||||
|
&crate::sampling::error::acp_error_message(err),
|
||||||
|
);
|
||||||
|
if detail.is_empty() {
|
||||||
|
RETRY_GUIDANCE.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{RETRY_GUIDANCE}\n{detail}")
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Map a deterministic failure's error text to a fixed, content-free
|
/// Map a deterministic failure's error text to a fixed, content-free
|
||||||
/// [`SuppressReason`] (drives telemetry + sticky-vs-per-turn scope).
|
/// [`SuppressReason`] (drives telemetry + sticky-vs-per-turn scope).
|
||||||
fn classify_suppress_reason(error_msg: &str) -> SuppressReason {
|
fn classify_suppress_reason(error_msg: &str) -> SuppressReason {
|
||||||
@@ -1945,7 +1958,7 @@ impl SessionActor {
|
|||||||
== SUPPRESS_NONE
|
== SUPPRESS_NONE
|
||||||
{
|
{
|
||||||
self.send_xai_notification(XaiSessionUpdate::AutoCompactFailed {
|
self.send_xai_notification(XaiSessionUpdate::AutoCompactFailed {
|
||||||
error: String::new(),
|
error: Self::failure_with_retry_guidance(&e),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -3286,3 +3299,28 @@ mod inline_auto_compact_flow_tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod failure_message_tests {
|
||||||
|
use super::SessionActor;
|
||||||
|
use agent_client_protocol as acp;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_failure_message_carries_guidance_and_the_decoded_detail() {
|
||||||
|
let err = acp::Error::internal_error().data(
|
||||||
|
"Compact failed: compact failed: HTTP 500 from upstream\n\nRequest URL: http://u:TEST_PASSWORD@host/v1",
|
||||||
|
);
|
||||||
|
let text = SessionActor::failure_with_retry_guidance(&err);
|
||||||
|
let (headline, detail) = text.split_once('\n').expect("guidance + detail");
|
||||||
|
assert_eq!(
|
||||||
|
headline,
|
||||||
|
"it'll retry on the next turn, or start a new session using /new."
|
||||||
|
);
|
||||||
|
assert_eq!(detail, "HTTP 500 from upstream");
|
||||||
|
assert!(!text.contains("TEST_PASSWORD"));
|
||||||
|
assert_eq!(
|
||||||
|
SessionActor::failure_with_retry_guidance(&acp::Error::internal_error().data(" ")),
|
||||||
|
"it'll retry on the next turn, or start a new session using /new."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -519,6 +519,9 @@ pub(crate) async fn capture_plan_changes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pin the `a/` `b/` header prefixes [`extract_changed_files`] parses.
|
||||||
|
const DIFF_PREFIX_ARGS: [&str; 2] = ["--src-prefix=a/", "--dst-prefix=b/"];
|
||||||
|
|
||||||
/// Run `git diff <baseline>` and return the FULL (untruncated) stdout;
|
/// Run `git diff <baseline>` and return the FULL (untruncated) stdout;
|
||||||
/// the caller extracts the changed-file list before truncating.
|
/// the caller extracts the changed-file list before truncating.
|
||||||
async fn run_git_diff_against_baseline(
|
async fn run_git_diff_against_baseline(
|
||||||
@@ -526,7 +529,7 @@ async fn run_git_diff_against_baseline(
|
|||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
) -> Result<String, ChangesCaptureError> {
|
) -> Result<String, ChangesCaptureError> {
|
||||||
let mut cmd = git_command(workspace_root);
|
let mut cmd = git_command(workspace_root);
|
||||||
cmd.arg("diff").arg(baseline);
|
cmd.arg("diff").args(DIFF_PREFIX_ARGS).arg(baseline);
|
||||||
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
|
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
|
||||||
Ok(Ok(output)) => output,
|
Ok(Ok(output)) => output,
|
||||||
Ok(Err(err)) => return Err(ChangesCaptureError::DiffCommandFailed(err.to_string())),
|
Ok(Err(err)) => return Err(ChangesCaptureError::DiffCommandFailed(err.to_string())),
|
||||||
@@ -579,10 +582,14 @@ async fn lazy_git_baseline_diff(
|
|||||||
// support SHA-256 repos.
|
// support SHA-256 repos.
|
||||||
let mut cmd = git_command(workspace_root);
|
let mut cmd = git_command(workspace_root);
|
||||||
if git_has_parent(workspace_root, &oldest).await {
|
if git_has_parent(workspace_root, &oldest).await {
|
||||||
cmd.arg("diff").arg(format!("{oldest}^..{head}"));
|
cmd.arg("diff")
|
||||||
|
.args(DIFF_PREFIX_ARGS)
|
||||||
|
.arg(format!("{oldest}^..{head}"));
|
||||||
} else {
|
} else {
|
||||||
let empty_tree = derive_empty_tree_sha(workspace_root).await;
|
let empty_tree = derive_empty_tree_sha(workspace_root).await;
|
||||||
cmd.arg("diff").arg(format!("{empty_tree}..{head}"));
|
cmd.arg("diff")
|
||||||
|
.args(DIFF_PREFIX_ARGS)
|
||||||
|
.arg(format!("{empty_tree}..{head}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
|
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
|
||||||
@@ -2192,4 +2199,38 @@ mod tests {
|
|||||||
"patch must carry the untracked-files note",
|
"patch must carry the untracked-files note",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn changed_files_ignore_the_host_diff_prefix_config() {
|
||||||
|
// Both keys rewrite the `diff --git a/<old> b/<new>` header the parser keys on.
|
||||||
|
for key in ["diff.mnemonicprefix", "diff.noprefix"] {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
init_repo(tmp.path());
|
||||||
|
git(tmp.path(), &["config", key, "true"]);
|
||||||
|
tokio::fs::write(tmp.path().join("tracked.txt"), b"seed\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let goal_created_at: i64 = 1_700_000_100;
|
||||||
|
git_at(tmp.path(), &["add", "."], goal_created_at + 100);
|
||||||
|
git_at(
|
||||||
|
tmp.path(),
|
||||||
|
&["commit", "-q", "-m", "post-goal"],
|
||||||
|
goal_created_at + 100,
|
||||||
|
);
|
||||||
|
let lazy = capture_changes_diff(None, tmp.path(), goal_created_at)
|
||||||
|
.await
|
||||||
|
.expect("lazy baseline must succeed");
|
||||||
|
assert_eq!(lazy.changed_files, ["tracked.txt"], "{key}: lazy baseline");
|
||||||
|
tokio::fs::write(tmp.path().join("tracked.txt"), b"changed\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let explicit = capture_changes_diff(Some("HEAD"), tmp.path(), goal_created_at)
|
||||||
|
.await
|
||||||
|
.expect("layer-1 git diff must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
explicit.changed_files,
|
||||||
|
["tracked.txt"],
|
||||||
|
"{key}: explicit baseline"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -467,7 +467,24 @@ fn compute_normalized_blocking(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if buf.len() >= original_bytes {
|
// A resize that falls under the vision floors cannot be sent; keep the original with the fallback note.
|
||||||
|
if new_w < MIN_VISION_SIDE_PX
|
||||||
|
|| new_h < MIN_VISION_SIDE_PX
|
||||||
|
|| u64::from(new_w) * u64::from(new_h) < MIN_VISION_TOTAL_PX
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
index,
|
||||||
|
width = new_w,
|
||||||
|
height = new_h,
|
||||||
|
"image resize fell under the minimum dimensions; keeping original attachment"
|
||||||
|
);
|
||||||
|
return Ok(NormalizedEntry::ReEncodingOversized {
|
||||||
|
bytes: Bytes::from(raw_bytes),
|
||||||
|
mime: Cow::Borrowed(orig_mime),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// An image over the dimension caps is resized even when that saves no bytes.
|
||||||
|
if !exceeded_dimensions && buf.len() >= original_bytes {
|
||||||
return Ok(NormalizedEntry::Unchanged {
|
return Ok(NormalizedEntry::Unchanged {
|
||||||
bytes: Bytes::from(raw_bytes),
|
bytes: Bytes::from(raw_bytes),
|
||||||
mime: Cow::Borrowed(orig_mime),
|
mime: Cow::Borrowed(orig_mime),
|
||||||
@@ -551,6 +568,50 @@ mod tests {
|
|||||||
image::ImageFormat::Png
|
image::ImageFormat::Png
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/// A flat image at the best PNG compression is tinier than any re-encode the fast encoder produces.
|
||||||
|
fn make_best_compressed_png_content(width: u32, height: u32) -> ImageContent {
|
||||||
|
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
|
||||||
|
use image::{ImageBuffer, ImageEncoder, Rgba};
|
||||||
|
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||||
|
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive)
|
||||||
|
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgba8)
|
||||||
|
.unwrap();
|
||||||
|
ImageContent::new(
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(&buf),
|
||||||
|
"image/png",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/// Over the dimension caps, a re-encode that saves no bytes is still a resize.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn over_dimension_image_is_resized_even_when_re_encode_saves_no_bytes() {
|
||||||
|
let img = make_best_compressed_png_content(3000, 2000);
|
||||||
|
let cache = fresh_cache();
|
||||||
|
match normalize_one_in(img, 1, false, &cache).await {
|
||||||
|
Outcome::Compressed { info, .. } => {
|
||||||
|
assert!(
|
||||||
|
info.compressed_bytes >= info.original_bytes,
|
||||||
|
"test premise: the re-encode must not shrink ({} vs {})",
|
||||||
|
info.compressed_bytes,
|
||||||
|
info.original_bytes
|
||||||
|
);
|
||||||
|
assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX);
|
||||||
|
assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX);
|
||||||
|
}
|
||||||
|
other => panic!("expected the oversized image to be downscaled, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A 3000x8 strip resizes to 2000x5, under the 8 px floor: it is kept with the fallback note.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resize_under_the_minimum_side_keeps_the_original() {
|
||||||
|
let img = make_best_compressed_png_content(3000, 8);
|
||||||
|
let cache = fresh_cache();
|
||||||
|
match normalize_one_in(img, 1, false, &cache).await {
|
||||||
|
Outcome::ReEncodingOversized(content) => assert_eq!(content.mime_type, "image/png"),
|
||||||
|
other => panic!("expected the strip to be kept with a fallback note, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
fn make_gif_content(width: u32, height: u32) -> ImageContent {
|
fn make_gif_content(width: u32, height: u32) -> ImageContent {
|
||||||
use image::{ImageBuffer, Rgba};
|
use image::{ImageBuffer, Rgba};
|
||||||
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||||
|
|||||||
@@ -359,11 +359,13 @@ pub enum PersistenceMsg {
|
|||||||
/// stays sequential with other summary.json mutations.
|
/// stays sequential with other summary.json mutations.
|
||||||
GeneratedTitle(String),
|
GeneratedTitle(String),
|
||||||
Flush,
|
Flush,
|
||||||
/// Flush all pending writes, then signal the caller once the flush is complete.
|
/// Flush all pending writes AND fsync the session files, then signal the
|
||||||
/// Unlike `Flush` (fire-and-forget), this is a **sync barrier**: the caller's
|
/// caller. Unlike `Flush` (fire-and-forget, page cache only), this is a
|
||||||
/// oneshot only resolves after `flush_pending()` finishes writing to disk.
|
/// **durability barrier**: `Ok` means every prior write is on stable
|
||||||
|
/// media; `Err` reports a storage write that failed since the last
|
||||||
|
/// barrier or a failed sync.
|
||||||
FlushAndAck {
|
FlushAndAck {
|
||||||
respond_to: tokio::sync::oneshot::Sender<()>,
|
respond_to: tokio::sync::oneshot::Sender<io::Result<()>>,
|
||||||
},
|
},
|
||||||
/// Flush all pending writes, then copy the current session directory contents and return
|
/// Flush all pending writes, then copy the current session directory contents and return
|
||||||
/// the in-memory snapshot to the caller (who can tar.gz + upload to GCS, etc.).
|
/// the in-memory snapshot to the caller (who can tar.gz + upload to GCS, etc.).
|
||||||
@@ -1392,6 +1394,9 @@ struct SessionPersistence {
|
|||||||
storage: Arc<dyn StorageAdapter>,
|
storage: Arc<dyn StorageAdapter>,
|
||||||
/// Pending ACP notification for merging consecutive text chunks
|
/// Pending ACP notification for merging consecutive text chunks
|
||||||
pending_notification: Option<acp::SessionNotification>,
|
pending_notification: Option<acp::SessionNotification>,
|
||||||
|
/// First storage failure since the last barrier: `FlushAndAck` must not
|
||||||
|
/// report `Ok` for state that never reached the file.
|
||||||
|
pending_write_error: Option<io::Error>,
|
||||||
rx: mpsc::UnboundedReceiver<PersistenceMsg>,
|
rx: mpsc::UnboundedReceiver<PersistenceMsg>,
|
||||||
/// Session title generation lifecycle.
|
/// Session title generation lifecycle.
|
||||||
summary: crate::session::summary::SummaryGenerator,
|
summary: crate::session::summary::SummaryGenerator,
|
||||||
@@ -1491,29 +1496,49 @@ impl SessionPersistence {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_update(&mut self, update: &SessionUpdate) {
|
fn note_storage_failure(&mut self, error: &io::Error) {
|
||||||
if let Err(e) = self.storage.append_update(&self.info, update).await {
|
if self.pending_write_error.is_none() {
|
||||||
tracing::warn!(?e, "failed to write update");
|
self.pending_write_error = Some(io::Error::new(error.kind(), error.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flush any pending merged ACP notification to disk.
|
/// Append an update; a failure is logged and latched for the next barrier.
|
||||||
async fn flush_pending(&mut self) {
|
async fn write_update(&mut self, update: &SessionUpdate) -> io::Result<()> {
|
||||||
// Write any pending merged ACP notification
|
let result = self.storage.append_update(&self.info, update).await;
|
||||||
if let Some(notification) = self.pending_notification.take() {
|
if let Err(error) = &result {
|
||||||
self.write_update(&SessionUpdate::Acp(Box::new(notification)))
|
tracing::warn!(%error, "failed to write update");
|
||||||
.await;
|
self.note_storage_failure(error);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the pending merged ACP notification.
|
||||||
|
async fn flush_pending(&mut self) -> io::Result<()> {
|
||||||
|
match self.pending_notification.take() {
|
||||||
|
Some(notification) => {
|
||||||
|
self.write_update(&SessionUpdate::Acp(Box::new(notification)))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flush pending writes and sync all session files to disk.
|
/// Fsync every session file to stable media.
|
||||||
/// Called before CopyFile to ensure all data is persisted.
|
async fn sync_files(&self) -> io::Result<()> {
|
||||||
async fn flush_and_sync(&mut self) {
|
let synced = self.storage.sync_session_files(&self.info).await;
|
||||||
self.flush_pending().await;
|
if let Err(error) = &synced {
|
||||||
// Sync all session files to disk to ensure they're actually written
|
tracing::warn!(%error, "failed to sync session files to disk");
|
||||||
if let Err(e) = self.storage.sync_session_files(&self.info).await {
|
|
||||||
tracing::warn!(?e, "Failed to sync session files to disk");
|
|
||||||
}
|
}
|
||||||
|
synced
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `FlushAndAck` barrier. First error wins: a write that failed since
|
||||||
|
/// the last barrier outranks a failed sync.
|
||||||
|
async fn flush_and_sync(&mut self) -> io::Result<()> {
|
||||||
|
let flushed = self.flush_pending().await;
|
||||||
|
let prior_write = self.pending_write_error.take().map_or(Ok(()), Err);
|
||||||
|
let synced = self.sync_files().await;
|
||||||
|
flushed.and(prior_write).and(synced)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(mut self) {
|
async fn run(mut self) {
|
||||||
@@ -1530,34 +1555,38 @@ impl SessionPersistence {
|
|||||||
}
|
}
|
||||||
match msg {
|
match msg {
|
||||||
PersistenceMsg::Flush => {
|
PersistenceMsg::Flush => {
|
||||||
self.flush_pending().await;
|
// Logged and latched by write_update; the next barrier reports it.
|
||||||
|
let _ = self.flush_pending().await;
|
||||||
}
|
}
|
||||||
PersistenceMsg::FlushAndAck { respond_to } => {
|
PersistenceMsg::FlushAndAck { respond_to } => {
|
||||||
self.flush_pending().await;
|
let result = self.flush_and_sync().await;
|
||||||
let _ = respond_to.send(());
|
let _ = respond_to.send(result);
|
||||||
}
|
}
|
||||||
PersistenceMsg::Update(update) => {
|
PersistenceMsg::Update(update) => {
|
||||||
match update {
|
match update {
|
||||||
SessionUpdate::Acp(notification) => {
|
SessionUpdate::Acp(notification) => {
|
||||||
// ACP notifications use merging to coalesce consecutive text chunks
|
// ACP notifications use merging to coalesce consecutive text chunks
|
||||||
if let Some(to_write) = self.maybe_merge_notification(¬ification) {
|
if let Some(to_write) = self.maybe_merge_notification(¬ification) {
|
||||||
self.write_update(&SessionUpdate::Acp(Box::new(to_write)))
|
// Logged and latched by write_update.
|
||||||
|
let _ = self
|
||||||
|
.write_update(&SessionUpdate::Acp(Box::new(to_write)))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SessionUpdate::Xai(_) => {
|
SessionUpdate::Xai(_) => {
|
||||||
// xAI notifications are written directly without merging
|
// Written directly without merging; logged and latched by write_update.
|
||||||
self.write_update(&update).await;
|
let _ = self.write_update(&update).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::Chat(chat_msg) => {
|
PersistenceMsg::Chat(chat_msg) => {
|
||||||
if let Err(e) = self
|
if let Err(error) = self
|
||||||
.storage
|
.storage
|
||||||
.append_chat_message(&self.info, &chat_msg)
|
.append_chat_message(&self.info, &chat_msg)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to write chat message");
|
tracing::warn!(%error, "failed to write chat message");
|
||||||
|
self.note_storage_failure(&error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::ReplaceChatHistory(messages) => {
|
PersistenceMsg::ReplaceChatHistory(messages) => {
|
||||||
@@ -1571,6 +1600,7 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to replace chat history");
|
tracing::warn!(?e, "failed to replace chat history");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::CurrentModel {
|
PersistenceMsg::CurrentModel {
|
||||||
@@ -1589,21 +1619,25 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to update current model");
|
tracing::warn!(?e, "failed to update current model");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::PlanState(state) => {
|
PersistenceMsg::PlanState(state) => {
|
||||||
if let Err(e) = self.storage.write_plan_state(&self.info, &state).await {
|
if let Err(e) = self.storage.write_plan_state(&self.info, &state).await {
|
||||||
tracing::warn!(?e, "failed to write plan state");
|
tracing::warn!(?e, "failed to write plan state");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::PlanModeState(state) => {
|
PersistenceMsg::PlanModeState(state) => {
|
||||||
if let Err(e) = self.storage.write_plan_mode_state(&self.info, &state).await {
|
if let Err(e) = self.storage.write_plan_mode_state(&self.info, &state).await {
|
||||||
tracing::warn!(?e, "failed to write plan mode state");
|
tracing::warn!(?e, "failed to write plan mode state");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::GoalModeState(state) => {
|
PersistenceMsg::GoalModeState(state) => {
|
||||||
if let Err(e) = self.storage.write_goal_mode_state(&self.info, &state).await {
|
if let Err(e) = self.storage.write_goal_mode_state(&self.info, &state).await {
|
||||||
tracing::warn!(?e, "failed to write goal mode state");
|
tracing::warn!(?e, "failed to write goal mode state");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::GraphModeState(state) => {
|
PersistenceMsg::GraphModeState(state) => {
|
||||||
@@ -1613,6 +1647,7 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to write graph mode state");
|
tracing::warn!(?e, "failed to write graph mode state");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::ContentChunk(content_chunks) => {
|
PersistenceMsg::ContentChunk(content_chunks) => {
|
||||||
@@ -1686,12 +1721,14 @@ impl SessionPersistence {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(?e, "failed to persist generated session title");
|
tracing::warn!(?e, "failed to persist generated session title");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::RewindPoint(point) => {
|
PersistenceMsg::RewindPoint(point) => {
|
||||||
if let Err(e) = self.storage.append_rewind_point(&self.info, &point).await {
|
if let Err(error) = self.storage.append_rewind_point(&self.info, &point).await {
|
||||||
tracing::warn!(?e, "failed to write rewind point");
|
tracing::warn!(%error, "failed to write rewind point");
|
||||||
|
self.note_storage_failure(&error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::TruncateRewindPoints { from_index } => {
|
PersistenceMsg::TruncateRewindPoints { from_index } => {
|
||||||
@@ -1701,6 +1738,7 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, from_index, "failed to truncate rewind points");
|
tracing::warn!(?e, from_index, "failed to truncate rewind points");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::MergeRewindPointsFrom { target_index } => {
|
PersistenceMsg::MergeRewindPointsFrom { target_index } => {
|
||||||
@@ -1710,6 +1748,7 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, target_index, "failed to merge rewind points");
|
tracing::warn!(?e, target_index, "failed to merge rewind points");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::CollectionId(collection_id) => {
|
PersistenceMsg::CollectionId(collection_id) => {
|
||||||
@@ -1719,6 +1758,7 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to write collection id");
|
tracing::warn!(?e, "failed to write collection id");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::NextTraceTurn {
|
PersistenceMsg::NextTraceTurn {
|
||||||
@@ -1731,11 +1771,13 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to write next trace turn");
|
tracing::warn!(?e, "failed to write next trace turn");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::Signals(signals) => {
|
PersistenceMsg::Signals(signals) => {
|
||||||
if let Err(e) = self.storage.write_signals(&self.info, &signals).await {
|
if let Err(e) = self.storage.write_signals(&self.info, &signals).await {
|
||||||
tracing::warn!(?e, "failed to write session signals");
|
tracing::warn!(?e, "failed to write session signals");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::AnnouncementState(state) => {
|
PersistenceMsg::AnnouncementState(state) => {
|
||||||
@@ -1745,16 +1787,19 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to write announcement state");
|
tracing::warn!(?e, "failed to write announcement state");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::Feedback(entry) => {
|
PersistenceMsg::Feedback(entry) => {
|
||||||
if let Err(e) = self.storage.append_feedback(&self.info, &entry).await {
|
if let Err(e) = self.storage.append_feedback(&self.info, &entry).await {
|
||||||
tracing::warn!(?e, "failed to write feedback entry");
|
tracing::warn!(?e, "failed to write feedback entry");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::Btw(entry) => {
|
PersistenceMsg::Btw(entry) => {
|
||||||
if let Err(e) = self.storage.append_btw(&self.info, &entry).await {
|
if let Err(e) = self.storage.append_btw(&self.info, &entry).await {
|
||||||
tracing::warn!(?e, "failed to write btw entry");
|
tracing::warn!(?e, "failed to write btw entry");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::GitHead { commit, branch } => {
|
PersistenceMsg::GitHead { commit, branch } => {
|
||||||
@@ -1764,6 +1809,7 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to persist git HEAD");
|
tracing::warn!(?e, "failed to persist git HEAD");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::CompactionCheckpoint(checkpoint) => {
|
PersistenceMsg::CompactionCheckpoint(checkpoint) => {
|
||||||
@@ -1773,6 +1819,7 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to write compaction checkpoint file");
|
tracing::warn!(?e, "failed to write compaction checkpoint file");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::CompactionRequest(request) => {
|
PersistenceMsg::CompactionRequest(request) => {
|
||||||
@@ -1782,11 +1829,13 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to write compaction request artifact");
|
tracing::warn!(?e, "failed to write compaction request artifact");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::RecapRequest(request) => {
|
PersistenceMsg::RecapRequest(request) => {
|
||||||
if let Err(e) = self.storage.write_recap_request(&self.info, &request).await {
|
if let Err(e) = self.storage.write_recap_request(&self.info, &request).await {
|
||||||
tracing::warn!(?e, "failed to write recap request artifact");
|
tracing::warn!(?e, "failed to write recap request artifact");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::CompactionSegment(segment) => {
|
PersistenceMsg::CompactionSegment(segment) => {
|
||||||
@@ -1796,20 +1845,25 @@ impl SessionPersistence {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(?e, "failed to write compaction segment");
|
tracing::warn!(?e, "failed to write compaction segment");
|
||||||
|
self.note_storage_failure(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PersistenceMsg::CopyFile { one_shot } => {
|
PersistenceMsg::CopyFile { one_shot } => {
|
||||||
// Flush pending writes and sync all session files to disk before copying.
|
// Not the durability barrier: the write-failure latch stays for FlushAndAck.
|
||||||
self.flush_and_sync().await;
|
let flushed = self.flush_pending().await;
|
||||||
|
let result = match flushed.and(self.sync_files().await) {
|
||||||
let result = self.copy_session_dir_to_memory().await;
|
Ok(()) => self.copy_session_dir_to_memory().await,
|
||||||
|
Err(error) => {
|
||||||
|
Err(anyhow::Error::new(error).context("session files are not on disk"))
|
||||||
|
}
|
||||||
|
};
|
||||||
let _ = one_shot.send(result);
|
let _ = one_shot.send(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain the merge buffer on channel close.
|
// Drain the merge buffer on channel close; a failure is logged by write_update.
|
||||||
self.flush_pending().await;
|
let _ = self.flush_pending().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn copy_session_dir_to_memory(&self) -> anyhow::Result<SessionStateCopy> {
|
async fn copy_session_dir_to_memory(&self) -> anyhow::Result<SessionStateCopy> {
|
||||||
@@ -2002,6 +2056,7 @@ pub(crate) async fn new(
|
|||||||
info: info_clone,
|
info: info_clone,
|
||||||
storage: storage.clone(),
|
storage: storage.clone(),
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
|
pending_write_error: None,
|
||||||
rx,
|
rx,
|
||||||
summary: crate::session::summary::SummaryGenerator::new(
|
summary: crate::session::summary::SummaryGenerator::new(
|
||||||
crate::session::summary::SummaryConfig {
|
crate::session::summary::SummaryConfig {
|
||||||
@@ -2070,6 +2125,7 @@ pub async fn new_with_explicit_dir(
|
|||||||
info: info_clone,
|
info: info_clone,
|
||||||
storage: storage.clone(),
|
storage: storage.clone(),
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
|
pending_write_error: None,
|
||||||
rx,
|
rx,
|
||||||
summary: crate::session::summary::SummaryGenerator::new(
|
summary: crate::session::summary::SummaryGenerator::new(
|
||||||
crate::session::summary::SummaryConfig {
|
crate::session::summary::SummaryConfig {
|
||||||
@@ -2181,6 +2237,7 @@ pub(crate) async fn load_light(
|
|||||||
info: loaded_info,
|
info: loaded_info,
|
||||||
storage: storage.clone(),
|
storage: storage.clone(),
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
|
pending_write_error: None,
|
||||||
rx,
|
rx,
|
||||||
summary: summary_gen,
|
summary: summary_gen,
|
||||||
registry_title_sync,
|
registry_title_sync,
|
||||||
@@ -3625,3 +3682,120 @@ mod repo_wide_resolution_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod barrier_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sampling_client() -> crate::sampling::Client {
|
||||||
|
crate::sampling::Client::new(kigi_sampler::SamplerConfig {
|
||||||
|
api_key: Some("test-key".to_string()),
|
||||||
|
base_url: "http://localhost".to_string(),
|
||||||
|
model: "test".to_string(),
|
||||||
|
max_completion_tokens: None,
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
api_backend: Default::default(),
|
||||||
|
chat_compat: Default::default(),
|
||||||
|
auth_scheme: Default::default(),
|
||||||
|
anthropic_oauth: false,
|
||||||
|
github_copilot: false,
|
||||||
|
openai_codex: false,
|
||||||
|
extra_headers: Default::default(),
|
||||||
|
context_window: 100_000,
|
||||||
|
force_http1: false,
|
||||||
|
max_retries: None,
|
||||||
|
stream_tool_calls: false,
|
||||||
|
idle_timeout_secs: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
origin_client: None,
|
||||||
|
attribution_callback: None,
|
||||||
|
bearer_resolver: None,
|
||||||
|
supports_backend_search: false,
|
||||||
|
compactions_remaining: None,
|
||||||
|
compaction_at_tokens: None,
|
||||||
|
doom_loop_recovery: None,
|
||||||
|
header_injector: None,
|
||||||
|
})
|
||||||
|
.expect("sampling client should build")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn barrier(handle: &PersistenceHandle) -> io::Result<()> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
handle
|
||||||
|
.tx
|
||||||
|
.send(PersistenceMsg::FlushAndAck { respond_to: tx })
|
||||||
|
.unwrap();
|
||||||
|
rx.await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn(dir: &std::path::Path) -> (Info, PersistenceHandle) {
|
||||||
|
let info = Info {
|
||||||
|
id: acp::SessionId::new("barrier"),
|
||||||
|
cwd: dir.to_string_lossy().into_owned(),
|
||||||
|
};
|
||||||
|
let handle = new_with_explicit_dir(
|
||||||
|
&info,
|
||||||
|
dir.to_path_buf(),
|
||||||
|
acp::ModelId::new("test-model"),
|
||||||
|
sampling_client(),
|
||||||
|
crate::test_support::TEST_MODEL.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(info, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chat append that never reached the file fails the next barrier once;
|
||||||
|
/// the barrier after that is clean again.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flush_and_ack_reports_a_lost_chat_append_once() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (info, handle) = spawn(dir.path()).await;
|
||||||
|
barrier(&handle).await.unwrap();
|
||||||
|
|
||||||
|
// A directory where the chat log should be makes the append fail.
|
||||||
|
let chat_file = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf())
|
||||||
|
.chat_file(&info);
|
||||||
|
let _ = std::fs::remove_file(&chat_file);
|
||||||
|
std::fs::create_dir(&chat_file).unwrap();
|
||||||
|
handle
|
||||||
|
.tx
|
||||||
|
.send(PersistenceMsg::Chat(ConversationItem::user("lost")))
|
||||||
|
.unwrap();
|
||||||
|
// CopyFile is an ordering fence that keeps the write-failure latch.
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
handle
|
||||||
|
.tx
|
||||||
|
.send(PersistenceMsg::CopyFile { one_shot: tx })
|
||||||
|
.unwrap();
|
||||||
|
let _ = rx.await.unwrap();
|
||||||
|
// With the path restored only the lost append can fail the barrier.
|
||||||
|
std::fs::remove_dir(&chat_file).unwrap();
|
||||||
|
let error = barrier(&handle).await.unwrap_err();
|
||||||
|
assert_ne!(error.kind(), io::ErrorKind::NotFound, "got {error}");
|
||||||
|
|
||||||
|
barrier(&handle).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A history rewrite that never landed fails the next barrier.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flush_and_ack_reports_a_failed_history_replacement() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (info, handle) = spawn(dir.path()).await;
|
||||||
|
let chat_file = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf())
|
||||||
|
.chat_file(&info);
|
||||||
|
// The rewrite stages a sibling temp file; a directory there fails it.
|
||||||
|
std::fs::create_dir(chat_file.with_extension("jsonl.tmp")).unwrap();
|
||||||
|
handle
|
||||||
|
.tx
|
||||||
|
.send(PersistenceMsg::ReplaceChatHistory(vec![
|
||||||
|
ConversationItem::user("rewritten"),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let error = barrier(&handle).await.unwrap_err();
|
||||||
|
assert_ne!(error.kind(), io::ErrorKind::NotFound, "got {error}");
|
||||||
|
|
||||||
|
barrier(&handle).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ impl JsonlStorageAdapter {
|
|||||||
fn updates_file(&self, info: &Info) -> PathBuf {
|
fn updates_file(&self, info: &Info) -> PathBuf {
|
||||||
self.session_dir(info).join("updates.jsonl")
|
self.session_dir(info).join("updates.jsonl")
|
||||||
}
|
}
|
||||||
fn chat_file(&self, info: &Info) -> PathBuf {
|
pub(crate) fn chat_file(&self, info: &Info) -> PathBuf {
|
||||||
self.session_dir(info).join("chat_history.jsonl")
|
self.session_dir(info).join("chat_history.jsonl")
|
||||||
}
|
}
|
||||||
fn summary_file(&self, info: &Info) -> PathBuf {
|
fn summary_file(&self, info: &Info) -> PathBuf {
|
||||||
@@ -700,7 +700,7 @@ impl JsonlStorageAdapter {
|
|||||||
options: super::CopySessionOptions,
|
options: super::CopySessionOptions,
|
||||||
) -> io::Result<super::CopySessionResult> {
|
) -> io::Result<super::CopySessionResult> {
|
||||||
let target_dir = self.session_dir(target_info);
|
let target_dir = self.session_dir(target_info);
|
||||||
std::fs::create_dir_all(&target_dir)?;
|
super::create_dir_all_durable(&target_dir)?;
|
||||||
let source_summary = self.read_summary_sync(source_info)?;
|
let source_summary = self.read_summary_sync(source_info)?;
|
||||||
let chat_format_version = source_summary.chat_format_version;
|
let chat_format_version = source_summary.chat_format_version;
|
||||||
let mut chat_to_copy: Vec<ConversationItem> =
|
let mut chat_to_copy: Vec<ConversationItem> =
|
||||||
@@ -920,7 +920,7 @@ async fn next_compaction_segment_index(compaction_dir: &std::path::Path) -> u64
|
|||||||
impl StorageAdapter for JsonlStorageAdapter {
|
impl StorageAdapter for JsonlStorageAdapter {
|
||||||
async fn init_session(&self, info: &Info, model_id: acp::ModelId) -> io::Result<Summary> {
|
async fn init_session(&self, info: &Info, model_id: acp::ModelId) -> io::Result<Summary> {
|
||||||
let dir = self.session_dir(info);
|
let dir = self.session_dir(info);
|
||||||
std::fs::create_dir_all(&dir)?;
|
super::create_dir_all_durable(&dir)?;
|
||||||
let summary_path = self.summary_file(info);
|
let summary_path = self.summary_file(info);
|
||||||
if Path::new(&summary_path).exists() {
|
if Path::new(&summary_path).exists() {
|
||||||
tracing::info!("Loading existing session from JSONL");
|
tracing::info!("Loading existing session from JSONL");
|
||||||
@@ -1283,29 +1283,10 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
async fn sync_session_files(&self, info: &Info) -> io::Result<()> {
|
async fn sync_session_files(&self, info: &Info) -> io::Result<()> {
|
||||||
let info_clone = info.clone();
|
let dir = self.session_dir(info);
|
||||||
let adapter_clone = self.clone();
|
tokio::task::spawn_blocking(move || super::sync_tree_durable(&dir))
|
||||||
tokio::task::spawn_blocking(move || -> io::Result<()> {
|
.await
|
||||||
use std::fs::OpenOptions;
|
.map_err(io::Error::other)?
|
||||||
let adapter = adapter_clone;
|
|
||||||
let files_to_sync = [
|
|
||||||
adapter.updates_file(&info_clone),
|
|
||||||
adapter.chat_file(&info_clone),
|
|
||||||
adapter.summary_file(&info_clone),
|
|
||||||
adapter.plan_file(&info_clone),
|
|
||||||
adapter.rewind_points_file(&info_clone),
|
|
||||||
];
|
|
||||||
for file_path in &files_to_sync {
|
|
||||||
if file_path.exists()
|
|
||||||
&& let Ok(file) = OpenOptions::new().write(true).open(file_path)
|
|
||||||
{
|
|
||||||
let _ = file.sync_all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(io::Error::other)?
|
|
||||||
}
|
}
|
||||||
async fn replace_chat_history(
|
async fn replace_chat_history(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -2794,3 +2794,24 @@ async fn graph_mode_state_round_trips_and_tombstones() {
|
|||||||
let after = adapter.load_session_without_updates(&info).await.unwrap();
|
let after = adapter.load_session_without_updates(&info).await.unwrap();
|
||||||
assert!(after.graph_mode_state.is_none(), "cleared graph must not resurrect");
|
assert!(after.graph_mode_state.is_none(), "cleared graph must not resurrect");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The barrier covers nested state files, and an unsyncable one fails it.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_session_files_reports_a_nested_file_that_cannot_be_synced() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
// Root opens anything; the permission probe is meaningless there.
|
||||||
|
if unsafe { libc::geteuid() } == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let adapter = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf());
|
||||||
|
let info = create_test_info();
|
||||||
|
adapter.init_session(&info, default_model_id()).await.unwrap();
|
||||||
|
let nested = adapter.goal_mode_state_file(&info);
|
||||||
|
std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&nested, b"{}").unwrap();
|
||||||
|
std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
let error = adapter.sync_session_files(&info).await.unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "got {error}");
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
|
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::extensions::notification::SessionNotification;
|
use crate::extensions::notification::SessionNotification;
|
||||||
@@ -15,6 +15,137 @@ use agent_client_protocol as acp;
|
|||||||
use kigi_sampling_types::ReasoningEffort;
|
use kigi_sampling_types::ReasoningEffort;
|
||||||
use kigi_workspace::session::file_state::RewindPoint;
|
use kigi_workspace::session::file_state::RewindPoint;
|
||||||
|
|
||||||
|
/// Fsync `file` to stable media; macOS `fsync` may stop at the drive cache.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(crate) fn sync_file_durable(file: &std::fs::File) -> io::Result<()> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
file.sync_all()?;
|
||||||
|
fullfsync_raw(file.as_raw_fd())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn fullfsync_raw(fd: std::os::fd::RawFd) -> io::Result<()> {
|
||||||
|
// SAFETY: fcntl on a live descriptor with no pointer arguments.
|
||||||
|
if unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) } == -1 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub(crate) fn sync_file_durable(file: &std::fs::File) -> io::Result<()> {
|
||||||
|
file.sync_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fsync `dir` so entries just created or renamed into it survive power loss.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(crate) fn sync_dir_durable(dir: &Path) -> io::Result<()> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let file = std::fs::File::open(dir)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
fullfsync_raw(file.as_raw_fd())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
pub(crate) fn sync_dir_durable(dir: &Path) -> io::Result<()> {
|
||||||
|
std::fs::File::open(dir)?.sync_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows has no directory-handle fsync; NTFS journals directory metadata.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) fn sync_dir_durable(_dir: &Path) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `bytes` to `tmp`, fsync them, rename over `dest`, fsync the parent:
|
||||||
|
/// a crash never leaves `dest` torn, and the rename never publishes
|
||||||
|
/// unsynced bytes over a synced file.
|
||||||
|
pub(crate) fn write_bytes_atomic(tmp: &Path, dest: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
let written = std::fs::File::create(tmp).and_then(|mut file| {
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
sync_file_durable(&file)
|
||||||
|
});
|
||||||
|
if let Err(error) = written {
|
||||||
|
let _ = std::fs::remove_file(tmp);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
crate::util::fs::replace_file(tmp, dest)?;
|
||||||
|
sync_parent_dir_durable(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_parent_dir_durable(path: &Path) -> io::Result<()> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?;
|
||||||
|
sync_dir_durable(parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_for_sync(path: &Path) -> io::Result<std::fs::File> {
|
||||||
|
let mut options = std::fs::OpenOptions::new();
|
||||||
|
options.read(true);
|
||||||
|
// FlushFileBuffers needs a writable handle; fsync does not.
|
||||||
|
options.write(cfg!(windows));
|
||||||
|
options.open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fsync every file and directory under `dir`, `dir` included, so all
|
||||||
|
/// session state reaches stable media. A path that vanished is not an error.
|
||||||
|
pub(crate) fn sync_tree_durable(dir: &Path) -> io::Result<()> {
|
||||||
|
let entries = match std::fs::read_dir(dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
let file_type = match entry.file_type() {
|
||||||
|
Ok(file_type) => file_type,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
if file_type.is_dir() {
|
||||||
|
sync_tree_durable(&entry.path())?;
|
||||||
|
} else if file_type.is_file() {
|
||||||
|
match open_for_sync(&entry.path()) {
|
||||||
|
Ok(file) => sync_file_durable(&file)?,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match sync_dir_durable(dir) {
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
result => result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `create_dir_all`, then fsync every directory that gained an entry so the
|
||||||
|
/// chain itself survives power loss. A directory that exists but is still
|
||||||
|
/// empty re-syncs a few ancestors: an earlier create may have died before
|
||||||
|
/// its sync.
|
||||||
|
pub(crate) fn create_dir_all_durable(dir: &Path) -> io::Result<()> {
|
||||||
|
let mut gaining_an_entry = Vec::new();
|
||||||
|
let mut cursor = dir;
|
||||||
|
while !cursor.exists() {
|
||||||
|
let Some(parent) = cursor.parent() else { break };
|
||||||
|
gaining_an_entry.push(parent.to_path_buf());
|
||||||
|
cursor = parent;
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
if gaining_an_entry.is_empty() && std::fs::read_dir(dir)?.next().is_none() {
|
||||||
|
gaining_an_entry.extend(dir.ancestors().skip(1).take(4).map(Path::to_path_buf));
|
||||||
|
}
|
||||||
|
for parent in gaining_an_entry.iter().filter(|parent| !is_fs_root(parent)) {
|
||||||
|
sync_dir_durable(parent)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_fs_root(path: &Path) -> bool {
|
||||||
|
path.parent()
|
||||||
|
.is_none_or(|parent| parent.as_os_str().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
pub mod jsonl;
|
pub mod jsonl;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod search_fts;
|
pub mod search_fts;
|
||||||
|
|||||||
@@ -228,12 +228,13 @@ fn read_summary(path: &Path) -> io::Result<Summary> {
|
|||||||
fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()> {
|
fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()> {
|
||||||
let bytes = serde_json::to_vec_pretty(summary)
|
let bytes = serde_json::to_vec_pretty(summary)
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
let tmp = summary_path.with_extension("json.tmp");
|
// Durable and Windows-safe: this write persists a session's CURRENT MODEL,
|
||||||
std::fs::write(&tmp, &bytes)?;
|
// and other writers (`/rename`) race the persistence barrier on this path.
|
||||||
// Windows-safe replace: this is the write that persists a session's
|
super::write_bytes_atomic(
|
||||||
// CURRENT MODEL — a bare rename made a switched model silently revert
|
&summary_path.with_extension("json.tmp"),
|
||||||
// on resume whenever AV/indexer held summary.json open on Windows.
|
summary_path,
|
||||||
crate::util::fs::replace_file(&tmp, summary_path)
|
&bytes,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -912,7 +912,10 @@ fn spawn_with_argv(
|
|||||||
if kigi_sandbox::should_restrict_child_network() {
|
if kigi_sandbox::should_restrict_child_network() {
|
||||||
// SAFETY: single prctl syscall (async-signal-safe).
|
// SAFETY: single prctl syscall (async-signal-safe).
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
|
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
|
||||||
|
cmd.pre_exec(move || {
|
||||||
|
kigi_sandbox::child_net::install_child_network_filter(filter)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ async fn request(handle: &PermissionHandle, access: AccessKind, id: &str) -> Dec
|
|||||||
session_id: None,
|
session_id: None,
|
||||||
subagent_type: None,
|
subagent_type: None,
|
||||||
subagent_description: None,
|
subagent_description: None,
|
||||||
|
// The accesses here carry no file path; the process cwd is a real cwd.
|
||||||
|
tool_cwd: AbsPathBuf::new(std::env::current_dir().unwrap()).unwrap(),
|
||||||
|
display_cwd: None,
|
||||||
};
|
};
|
||||||
let PermissionHandle::Actor { cmd_tx, .. } = handle else {
|
let PermissionHandle::Actor { cmd_tx, .. } = handle else {
|
||||||
panic!("expected actor handle");
|
panic!("expected actor handle");
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ impl LeaderStdioClient {
|
|||||||
.env("KIGI_TRACE_UPLOAD", "false")
|
.env("KIGI_TRACE_UPLOAD", "false")
|
||||||
.env("KIGI_INSTRUMENTATION", "disabled")
|
.env("KIGI_INSTRUMENTATION", "disabled")
|
||||||
// Inherited by the spawned leader, whose stderr goes to
|
// Inherited by the spawned leader, whose stderr goes to
|
||||||
// ~/.kigi/leader.log — keep it chatty for diagnosis.
|
// ~/.kigi/logs/leader.log — keep it chatty for diagnosis.
|
||||||
.env("RUST_LOG", "kigi_shell=debug");
|
.env("RUST_LOG", "kigi_shell=debug");
|
||||||
|
|
||||||
let (mut child, stderr) = spawn_piped_with_stderr_capture(cmd);
|
let (mut child, stderr) = spawn_piped_with_stderr_capture(cmd);
|
||||||
@@ -345,5 +345,5 @@ pub async fn wait_for_replay_notifications(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn leader_log(home: &Path) -> String {
|
pub fn leader_log(home: &Path) -> String {
|
||||||
std::fs::read_to_string(home.join(".kigi").join("leader.log")).unwrap_or_default()
|
std::fs::read_to_string(home.join(".kigi").join("logs").join("leader.log")).unwrap_or_default()
|
||||||
}
|
}
|
||||||
@@ -634,7 +634,8 @@ impl LocalTerminalActor {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if kigi_sandbox::should_restrict_child_network() {
|
if kigi_sandbox::should_restrict_child_network() {
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
|
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
|
||||||
|
cmd.pre_exec(move || kigi_sandbox::child_net::install_child_network_filter(filter));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2646,7 +2647,8 @@ fn spawn_shell_command(
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if kigi_sandbox::should_restrict_child_network() {
|
if kigi_sandbox::should_restrict_child_network() {
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
|
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
|
||||||
|
cmd.pre_exec(move || kigi_sandbox::child_net::install_child_network_filter(filter));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmd
|
cmd
|
||||||
|
|||||||
@@ -585,6 +585,19 @@ pub fn dup_tui_stderr() -> io::Result<std::fs::File> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point fd 2 at `/dev/null` for the rest of the process: the terminal stopped reading, so any further stderr write would block.
|
||||||
|
pub fn silence_native_stderr() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
let Ok(devnull) = std::fs::OpenOptions::new().write(true).open("/dev/null") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// SAFETY: devnull is a valid descriptor; fd 2 is a valid target.
|
||||||
|
unsafe { libc::dup2(devnull.as_raw_fd(), 2) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Restore fd 2 to point to the real terminal stderr.
|
/// Restore fd 2 to point to the real terminal stderr.
|
||||||
///
|
///
|
||||||
/// Call this before exiting so that any final messages (panic output,
|
/// Call this before exiting so that any final messages (panic output,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ kigi --sandbox workspace
|
|||||||
# Read-only mode (read everywhere, write only to ~/.kigi/ + temp dirs)
|
# Read-only mode (read everywhere, write only to ~/.kigi/ + temp dirs)
|
||||||
kigi --sandbox read-only
|
kigi --sandbox read-only
|
||||||
|
|
||||||
# Most restrictive profile (read CWD + system paths, write CWD + temp dirs + ~/.kigi/, no child network)
|
# Most restrictive profile (read CWD + system paths, write CWD + temp dirs + ~/.kigi/ state dirs, no child network)
|
||||||
kigi --sandbox strict
|
kigi --sandbox strict
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -29,10 +29,12 @@ kigi --sandbox strict
|
|||||||
| `workspace` | Everywhere | CWD + `~/.kigi/` + `/tmp` + `/var/tmp` | Allowed | Normal development |
|
| `workspace` | Everywhere | CWD + `~/.kigi/` + `/tmp` + `/var/tmp` | Allowed | Normal development |
|
||||||
| `devbox` | Everywhere | All top-level dirs except `/data` | Allowed | Disposable dev VMs |
|
| `devbox` | Everywhere | All top-level dirs except `/data` | Allowed | Disposable dev VMs |
|
||||||
| `read-only` | Everywhere | `~/.kigi/` + `/tmp` + `/var/tmp` | Blocked¹ | Exploration, code review |
|
| `read-only` | Everywhere | `~/.kigi/` + `/tmp` + `/var/tmp` | Blocked¹ | Exploration, code review |
|
||||||
| `strict` | CWD + system paths | CWD + `~/.kigi/` + `/tmp` + `/var/tmp` | Blocked¹ | Untrusted code |
|
| `strict` | CWD + system paths | CWD + `/tmp` + `/var/tmp` + `~/.kigi/` state dirs² | Blocked¹ | Untrusted code |
|
||||||
|
|
||||||
¹ Child-network blocking is enforced on **Linux only** (via seccomp). On macOS it is a no-op — these profiles do not restrict child-process network there.
|
¹ Child-network blocking is enforced on **Linux only** (via seccomp). On macOS it is a no-op — these profiles do not restrict child-process network there.
|
||||||
|
|
||||||
|
² `run`, `sessions`, `worktrees`, `worktree_pool`, `tasks`, `memory`, `logs`, `debug`, `crash`, `memtrace`, `trace-exports`. The rest of `~/.kigi/` (`config.toml`, `auth.json`, `sandbox.toml`, `hooks/`, `bin/`, `vendor/`) is read-only, so a sandboxed command cannot change the next launch. Root-level caches (`version.json`, `slash-mru.json`, `pager.toml` settings) are not written under `strict`.
|
||||||
|
|
||||||
To block specific files (e.g. `.env` or credential paths) on top of a profile, define a [custom profile](#custom-profiles) with a `deny` list — it is kernel-enforced (read + write/rename) and supports glob patterns like `**/*.pem`.
|
To block specific files (e.g. `.env` or credential paths) on top of a profile, define a [custom profile](#custom-profiles) with a `deny` list — it is kernel-enforced (read + write/rename) and supports glob patterns like `**/*.pem`.
|
||||||
|
|
||||||
### Profile Details
|
### Profile Details
|
||||||
@@ -43,7 +45,7 @@ To block specific files (e.g. `.env` or credential paths) on top of a profile, d
|
|||||||
|
|
||||||
**read-only** -- Use when you want the agent to analyze code without modifying your project files. The agent can read everything but can only write to `~/.kigi/` (needed for session persistence) and temp directories. Child-process network access is blocked on Linux (no-op on macOS).
|
**read-only** -- Use when you want the agent to analyze code without modifying your project files. The agent can read everything but can only write to `~/.kigi/` (needed for session persistence) and temp directories. Child-process network access is blocked on Linux (no-op on macOS).
|
||||||
|
|
||||||
**strict** -- The most restrictive profile, for reviewing untrusted code. The agent can only read files within the current working directory and essential system paths. Writes are limited to CWD, `~/.kigi/`, and temp directories. Child-process network access is blocked on Linux (no-op on macOS).
|
**strict** -- The most restrictive profile, for reviewing untrusted code. The agent can only read files within the current working directory, essential system paths, and `~/.kigi/`. Writes are limited to CWD, the `~/.kigi/` state directories (sessions, worktrees, memory, logs), and temp directories; `config.toml`, `auth.json`, `hooks/` and `bin/` stay read-only. Child-process network access is blocked on Linux (no-op on macOS).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ In practice, on Linux this means:
|
|||||||
|
|
||||||
## Event Logging
|
## Event Logging
|
||||||
|
|
||||||
Sandbox events are logged to `~/.kigi/sandbox-events.jsonl` for debugging. Events include:
|
Sandbox events are logged to `~/.kigi/sessions/sandbox-events.jsonl` for debugging. Events include:
|
||||||
|
|
||||||
- Profile applied (which profile, timestamp)
|
- Profile applied (which profile, timestamp)
|
||||||
- Violations (attempted access to denied paths)
|
- Violations (attempted access to denied paths)
|
||||||
|
|||||||
@@ -270,10 +270,10 @@ Path patterns are globs matched against the path string the tool was called with
|
|||||||
- `*` and `?` do not cross `/`; `**` does. `Read(src/*)` matches `src/main.rs` but not `src/nested/mod.rs`; use `Read(src/**)` for the whole tree.
|
- `*` and `?` do not cross `/`; `**` does. `Read(src/*)` matches `src/main.rs` but not `src/nested/mod.rs`; use `Read(src/**)` for the whole tree.
|
||||||
- A bare filename matches only that exact string. Use `**/.env` to match `.env` at any depth.
|
- A bare filename matches only that exact string. Use `**/.env` to match `.env` at any depth.
|
||||||
- There are no anchor prefixes: a leading `//` or `~/` in a pattern is treated as literal glob text. Write absolute-path patterns or `**/` patterns instead.
|
- There are no anchor prefixes: a leading `//` or `~/` in a pattern is treated as literal glob text. Write absolute-path patterns or `**/` patterns instead.
|
||||||
- Paths are matched as given, without canonicalization. Whether a path is absolute or relative depends on how the tool was invoked, so patterns intended as boundaries should cover both forms (for example both `/repo/secrets/**` and `secrets/**`).
|
- Allow rules match the path as the tool was called with it. Deny and ask rules are also checked against the file the tool will open: the path is resolved against the session directory, symlinks are followed, and the absolute and the workspace-relative spelling (with or without a leading `./`) are matched. `secrets/**` therefore catches a read of `/repo/secrets/key` and a read through a symlink that points into `secrets/`. A symlink that cannot be resolved (a loop) prompts when any deny or ask rule covers the tool.
|
||||||
- `Read` rules also govern `grep` searches; `Grep(...)` rules match only grep.
|
- `Read` rules also govern `grep` searches; `Grep(...)` rules match only grep.
|
||||||
|
|
||||||
`Read` and `Edit` deny rules additionally apply to file paths that shell commands touch (for example `cat` or `sed` on a denied path), and that shell-level check resolves symlinks. The direct `read_file`/`search_replace` tool checks do not resolve symlinks. For OS-level enforcement that covers every process, combine deny rules with the sandbox ([18-sandbox.md](18-sandbox.md)).
|
`Read` and `Edit` deny rules additionally apply to file paths that shell commands touch (for example `cat` or `sed` on a denied path), and that shell-level check resolves symlinks too. For OS-level enforcement that covers every process, combine deny rules with the sandbox ([18-sandbox.md](18-sandbox.md)).
|
||||||
|
|
||||||
### MCP Rules
|
### MCP Rules
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ pub(super) fn refresh_context_used(view: &mut AgentView, used: u64) {
|
|||||||
let total = view.session.models.get_context_window().unwrap_or(0);
|
let total = view.session.models.get_context_window().unwrap_or(0);
|
||||||
view.apply_context_used(used, total);
|
view.apply_context_used(used, total);
|
||||||
}
|
}
|
||||||
|
/// The count a compaction lifecycle update carries for the context bar: the banner's "N% full" must not sit next to a stale pre-turn number.
|
||||||
|
pub(super) fn compaction_context_refresh(update: &XaiSessionUpdate) -> Option<u64> {
|
||||||
|
match update {
|
||||||
|
XaiSessionUpdate::AutoCompactStarted { tokens_used, .. } => Some(*tokens_used),
|
||||||
|
XaiSessionUpdate::AutoCompactCompleted { tokens_after, .. } => Some(*tokens_after),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Refresh the bar and record `used` as the confirmed count for a pending
|
/// Refresh the bar and record `used` as the confirmed count for a pending
|
||||||
/// compaction message; call only from the `meta.totalTokens` path.
|
/// compaction message; call only from the `meta.totalTokens` path.
|
||||||
pub(super) fn confirm_context_used(view: &mut AgentView, used: u64) {
|
pub(super) fn confirm_context_used(view: &mut AgentView, used: u64) {
|
||||||
@@ -197,8 +205,10 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||||||
&mut agent.scrollback,
|
&mut agent.scrollback,
|
||||||
is_api_key_auth,
|
is_api_key_auth,
|
||||||
);
|
);
|
||||||
if let XaiSessionUpdate::AutoCompactCompleted { tokens_after, .. } = update {
|
if let Some(used) = compaction_context_refresh(update) {
|
||||||
refresh_context_used(agent, *tokens_after);
|
refresh_context_used(agent, used);
|
||||||
|
}
|
||||||
|
if let XaiSessionUpdate::AutoCompactCompleted { .. } = update {
|
||||||
agent.todo.update_todos(Vec::new());
|
agent.todo.update_todos(Vec::new());
|
||||||
}
|
}
|
||||||
changed
|
changed
|
||||||
@@ -812,6 +822,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||||||
XaiSessionUpdate::ModelChanged {
|
XaiSessionUpdate::ModelChanged {
|
||||||
model_id,
|
model_id,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
|
context_window,
|
||||||
} => {
|
} => {
|
||||||
if agent.session.model_switch_pending {
|
if agent.session.model_switch_pending {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -847,6 +858,8 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||||||
.session
|
.session
|
||||||
.models
|
.models
|
||||||
.set_current(new_model_id.clone(), effort);
|
.set_current(new_model_id.clone(), effort);
|
||||||
|
let window_changed =
|
||||||
|
context_window.is_some_and(|total| agent.apply_context_window(total));
|
||||||
agent.session.user_model_preference = Some(new_model_id.clone());
|
agent.session.user_model_preference = Some(new_model_id.clone());
|
||||||
let resolved_effort = agent.session.models.reasoning_effort;
|
let resolved_effort = agent.session.models.reasoning_effort;
|
||||||
let actually_changed =
|
let actually_changed =
|
||||||
@@ -858,7 +871,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||||||
"ModelChanged broadcast applied (remote switch)"
|
"ModelChanged broadcast applied (remote switch)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
actually_changed
|
actually_changed || window_changed
|
||||||
}
|
}
|
||||||
XaiSessionUpdate::MemoryFiles { files } => {
|
XaiSessionUpdate::MemoryFiles { files } => {
|
||||||
let entries = crate::views::memory_modal::build_entries(files);
|
let entries = crate::views::memory_modal::build_entries(files);
|
||||||
@@ -1068,10 +1081,7 @@ pub(super) fn handle_child_session_notification(
|
|||||||
| XaiSessionUpdate::AutoCompactFailed { .. }
|
| XaiSessionUpdate::AutoCompactFailed { .. }
|
||||||
| XaiSessionUpdate::AutoCompactCancelled { .. }
|
| XaiSessionUpdate::AutoCompactCancelled { .. }
|
||||||
| XaiSessionUpdate::RetryState(_) => {
|
| XaiSessionUpdate::RetryState(_) => {
|
||||||
let compact_tokens = match &update {
|
let compact_tokens = compaction_context_refresh(&update);
|
||||||
XaiSessionUpdate::AutoCompactCompleted { tokens_after, .. } => Some(*tokens_after),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
if let Some(child_view) = agent.subagent_views.get_mut(child_sid) {
|
if let Some(child_view) = agent.subagent_views.get_mut(child_sid) {
|
||||||
changed = apply_session_event(
|
changed = apply_session_event(
|
||||||
|
|||||||
@@ -1701,12 +1701,14 @@ pub(super) fn model_changed_ext(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
reasoning_effort: Option<&str>,
|
reasoning_effort: Option<&str>,
|
||||||
|
context_window: Option<u64>,
|
||||||
) -> acp::ExtNotification {
|
) -> acp::ExtNotification {
|
||||||
let payload = SessionNotification {
|
let payload = SessionNotification {
|
||||||
session_id: acp::SessionId::new(session_id),
|
session_id: acp::SessionId::new(session_id),
|
||||||
update: XaiSessionUpdate::ModelChanged {
|
update: XaiSessionUpdate::ModelChanged {
|
||||||
model_id: model_id.to_string(),
|
model_id: model_id.to_string(),
|
||||||
reasoning_effort: reasoning_effort.map(String::from),
|
reasoning_effort: reasoning_effort.map(String::from),
|
||||||
|
context_window,
|
||||||
},
|
},
|
||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
@@ -1723,6 +1725,7 @@ pub(super) fn model_changed_ext_with_event(
|
|||||||
update: XaiSessionUpdate::ModelChanged {
|
update: XaiSessionUpdate::ModelChanged {
|
||||||
model_id: model_id.to_string(),
|
model_id: model_id.to_string(),
|
||||||
reasoning_effort: None,
|
reasoning_effort: None,
|
||||||
|
context_window: None,
|
||||||
},
|
},
|
||||||
meta: Some(serde_json::json!({ "eventId" : event_id })),
|
meta: Some(serde_json::json!({ "eventId" : event_id })),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,6 +110,70 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seed_windowed_models(agent: &mut AgentView) {
|
||||||
|
for (slug, window) in [("wide", 1_000_000u64), ("narrow", 262_144)] {
|
||||||
|
let mut info = make_model_info(slug);
|
||||||
|
info.meta = serde_json::json!({ "totalContextTokens": window })
|
||||||
|
.as_object()
|
||||||
|
.cloned();
|
||||||
|
agent
|
||||||
|
.session
|
||||||
|
.models
|
||||||
|
.available
|
||||||
|
.insert(acp::ModelId::new(std::sync::Arc::from(slug)), info);
|
||||||
|
}
|
||||||
|
agent
|
||||||
|
.session
|
||||||
|
.models
|
||||||
|
.set_current(acp::ModelId::new(std::sync::Arc::from("wide")), None);
|
||||||
|
agent.apply_context_used(15_000, 1_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A follower mirrors a remote switch and installs the effective window
|
||||||
|
/// the broadcast carries, like the invoking client does.
|
||||||
|
#[test]
|
||||||
|
fn model_changed_broadcast_installs_the_reported_context_window() {
|
||||||
|
let mut app = make_app_with_agent("sess-1");
|
||||||
|
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
|
||||||
|
|
||||||
|
let notif = model_changed_ext("sess-1", "narrow", None, Some(262_144));
|
||||||
|
assert!(handle_ext_notification(¬if, &mut app));
|
||||||
|
|
||||||
|
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
|
||||||
|
assert_eq!((snap.used, snap.total, snap.usage_pct), (15_000, 262_144, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same model, same effort, new effective window (the catalog was
|
||||||
|
/// reloaded and re-applied): the follower must still request a redraw.
|
||||||
|
#[test]
|
||||||
|
fn model_changed_same_model_new_window_requests_redraw() {
|
||||||
|
let mut app = make_app_with_agent("sess-1");
|
||||||
|
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
|
||||||
|
|
||||||
|
let notif = model_changed_ext("sess-1", "wide", None, Some(2_000_000));
|
||||||
|
assert!(handle_ext_notification(¬if, &mut app));
|
||||||
|
|
||||||
|
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
|
||||||
|
assert_eq!((snap.used, snap.total), (15_000, 2_000_000));
|
||||||
|
|
||||||
|
let again = model_changed_ext("sess-1", "wide", None, Some(2_000_000));
|
||||||
|
assert!(!handle_ext_notification(&again, &mut app));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A broadcast from a leader that predates the field carries no window;
|
||||||
|
/// the snapshot is left for the next streaming update to refresh.
|
||||||
|
#[test]
|
||||||
|
fn model_changed_broadcast_without_window_keeps_the_snapshot() {
|
||||||
|
let mut app = make_app_with_agent("sess-1");
|
||||||
|
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
|
||||||
|
|
||||||
|
let notif = model_changed_ext("sess-1", "narrow", None, None);
|
||||||
|
assert!(handle_ext_notification(¬if, &mut app));
|
||||||
|
|
||||||
|
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
|
||||||
|
assert_eq!((snap.used, snap.total), (15_000, 1_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn models_update_without_active_agent_uses_shell_default() {
|
fn models_update_without_active_agent_uses_shell_default() {
|
||||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
@@ -232,7 +296,7 @@
|
|||||||
let scrollback_before = agent.scrollback.len();
|
let scrollback_before = agent.scrollback.len();
|
||||||
assert!(!agent.session.model_switch_pending);
|
assert!(!agent.session.model_switch_pending);
|
||||||
|
|
||||||
let notif = model_changed_ext("sess-1", "kigi-4", None);
|
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
|
||||||
let changed = handle_ext_notification(¬if, &mut app);
|
let changed = handle_ext_notification(¬if, &mut app);
|
||||||
assert!(
|
assert!(
|
||||||
changed,
|
changed,
|
||||||
@@ -278,7 +342,7 @@
|
|||||||
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
|
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
|
||||||
assert!(!agent.session.model_switch_pending);
|
assert!(!agent.session.model_switch_pending);
|
||||||
|
|
||||||
let notif = model_changed_ext("sess-1", "auto", None);
|
let notif = model_changed_ext("sess-1", "auto", None, None);
|
||||||
let changed = handle_ext_notification(¬if, &mut app);
|
let changed = handle_ext_notification(¬if, &mut app);
|
||||||
assert!(
|
assert!(
|
||||||
changed,
|
changed,
|
||||||
@@ -328,7 +392,7 @@
|
|||||||
agent.session.model_switch_pending = true;
|
agent.session.model_switch_pending = true;
|
||||||
let scrollback_before = agent.scrollback.len();
|
let scrollback_before = agent.scrollback.len();
|
||||||
|
|
||||||
let notif = model_changed_ext("sess-1", "kigi-4", None);
|
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
|
||||||
let changed = handle_ext_notification(¬if, &mut app);
|
let changed = handle_ext_notification(¬if, &mut app);
|
||||||
assert!(
|
assert!(
|
||||||
!changed,
|
!changed,
|
||||||
@@ -369,7 +433,7 @@
|
|||||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||||
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
||||||
|
|
||||||
let notif = model_changed_ext("sess-1", "kigi-99-unknown", None);
|
let notif = model_changed_ext("sess-1", "kigi-99-unknown", None, None);
|
||||||
let changed = handle_ext_notification(¬if, &mut app);
|
let changed = handle_ext_notification(¬if, &mut app);
|
||||||
assert!(
|
assert!(
|
||||||
!changed,
|
!changed,
|
||||||
@@ -400,7 +464,7 @@
|
|||||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||||
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
||||||
|
|
||||||
let notif = model_changed_ext("sess-1", "kigi-4", Some("high"));
|
let notif = model_changed_ext("sess-1", "kigi-4", Some("high"), None);
|
||||||
assert!(handle_ext_notification(¬if, &mut app));
|
assert!(handle_ext_notification(¬if, &mut app));
|
||||||
|
|
||||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||||
@@ -422,7 +486,7 @@
|
|||||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||||
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
||||||
|
|
||||||
let notif = model_changed_ext("sess-OTHER", "kigi-4", None);
|
let notif = model_changed_ext("sess-OTHER", "kigi-4", None, None);
|
||||||
let changed = handle_ext_notification(¬if, &mut app);
|
let changed = handle_ext_notification(¬if, &mut app);
|
||||||
assert!(!changed);
|
assert!(!changed);
|
||||||
|
|
||||||
|
|||||||
@@ -576,9 +576,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn child_compact_started_does_not_reset_context_used() {
|
fn child_compact_started_refreshes_context_used() {
|
||||||
// Sibling variants in the same outer arm must not touch the numerator;
|
// AutoCompactStarted carries the count the trigger fired on; the child bar must show it.
|
||||||
// guards against accidental widening of the AutoCompactCompleted gate.
|
|
||||||
let mut agent = make_agent(Some("root-sess"));
|
let mut agent = make_agent(Some("root-sess"));
|
||||||
let child_sid = "child-sess-3";
|
let child_sid = "child-sess-3";
|
||||||
agent
|
agent
|
||||||
@@ -603,7 +602,17 @@
|
|||||||
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
child_view.context_state.as_ref().map(|c| c.used),
|
child_view.context_state.as_ref().map(|c| c.used),
|
||||||
Some(90_000)
|
Some(95_000)
|
||||||
|
);
|
||||||
|
// A sibling variant without a count leaves the numerator alone.
|
||||||
|
let cancelled = XaiSessionUpdate::AutoCompactCancelled {
|
||||||
|
reason: "user".into(),
|
||||||
|
};
|
||||||
|
let _ = handle_child_session_notification(cancelled, child_sid, &mut agent, false);
|
||||||
|
let child_view = agent.subagent_views.get(child_sid).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
child_view.context_state.as_ref().map(|c| c.used),
|
||||||
|
Some(95_000)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,5 +702,30 @@
|
|||||||
!session.model_incompatible,
|
!session.model_incompatible,
|
||||||
"non-encrypted_content error types must not set model_incompatible"
|
"non-encrypted_content error types must not set model_incompatible"
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The banner's count must also refresh the context bar.
|
||||||
|
#[test]
|
||||||
|
fn auto_compact_started_refreshes_the_context_bar() {
|
||||||
|
let mut app = make_app_with_agent("sess-1");
|
||||||
|
let id = AgentId(0);
|
||||||
|
app.agents.get_mut(&id).unwrap().apply_context_used(15_000, 262_144);
|
||||||
|
let payload = SessionNotification {
|
||||||
|
session_id: acp::SessionId::new("sess-1"),
|
||||||
|
update: XaiSessionUpdate::AutoCompactStarted {
|
||||||
|
tokens_used: 90_000,
|
||||||
|
context_window: 262_144,
|
||||||
|
percentage: 34,
|
||||||
|
reason: "threshold".into(),
|
||||||
|
},
|
||||||
|
meta: None,
|
||||||
|
};
|
||||||
|
let notif = acp::ExtNotification::new(
|
||||||
|
"kigi/session/update",
|
||||||
|
std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()),
|
||||||
|
);
|
||||||
|
let _ = handle_ext_notification(¬if, &mut app);
|
||||||
|
let bar = app.agents[&id].context_state.as_ref().map(|c| (c.used, c.total));
|
||||||
|
assert_eq!(bar, Some((90_000, 262_144)));
|
||||||
|
}
|
||||||
@@ -1431,6 +1431,8 @@ pub enum Effect {
|
|||||||
Compact {
|
Compact {
|
||||||
agent_id: AgentId,
|
agent_id: AgentId,
|
||||||
session_id: acp::SessionId,
|
session_id: acp::SessionId,
|
||||||
|
/// `/compact <instructions>`, forwarded as `userContext`.
|
||||||
|
user_context: Option<String>,
|
||||||
},
|
},
|
||||||
/// Kill a background task.
|
/// Kill a background task.
|
||||||
KillBgTask {
|
KillBgTask {
|
||||||
@@ -2112,7 +2114,10 @@ pub enum TaskResult {
|
|||||||
agent_id: AgentId,
|
agent_id: AgentId,
|
||||||
model_id: acp::ModelId,
|
model_id: acp::ModelId,
|
||||||
effort: Option<ReasoningEffort>,
|
effort: Option<ReasoningEffort>,
|
||||||
result: Result<(), SwitchModelError>,
|
/// Ok carries the effective context window the shell reported
|
||||||
|
/// (`totalContextTokens` in the response meta; `None` from a shell
|
||||||
|
/// that predates it).
|
||||||
|
result: Result<Option<u64>, SwitchModelError>,
|
||||||
/// Forwarded from `Effect::SwitchModel.prev_model_id` for
|
/// Forwarded from `Effect::SwitchModel.prev_model_id` for
|
||||||
/// rollback on `IncompatibleAgent`.
|
/// rollback on `IncompatibleAgent`.
|
||||||
prev_model_id: Option<acp::ModelId>,
|
prev_model_id: Option<acp::ModelId>,
|
||||||
|
|||||||
@@ -734,7 +734,8 @@ impl AgentView {
|
|||||||
Event::Mouse(mouse) => self.handle_inline_edit_mouse(mouse),
|
Event::Mouse(mouse) => self.handle_inline_edit_mouse(mouse),
|
||||||
Event::Paste(text) => {
|
Event::Paste(text) => {
|
||||||
if let Some(ref mut edit) = self.inline_edit {
|
if let Some(ref mut edit) = self.inline_edit {
|
||||||
edit.textarea.insert_str(text);
|
edit.textarea
|
||||||
|
.insert_str(&crate::views::prompt_widget::normalize_line_breaks(text));
|
||||||
}
|
}
|
||||||
InputOutcome::Changed
|
InputOutcome::Changed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1231,6 +1231,17 @@ mod cancel_turn_mouse_tests {
|
|||||||
use crate::views::modal::{CancelTurnChoice, CancelTurnViewState};
|
use crate::views::modal::{CancelTurnChoice, CancelTurnViewState};
|
||||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
#[test]
|
||||||
|
fn queued_post_flush_escapes_drain_in_order() {
|
||||||
|
let mut agent = make_agent();
|
||||||
|
agent.queue_post_flush(crate::terminal::overlay::PostFlush::plain("a".into()));
|
||||||
|
agent.queue_post_flush(crate::terminal::overlay::PostFlush::plain("b".into()));
|
||||||
|
let drained = agent
|
||||||
|
.take_pending_post_flush()
|
||||||
|
.map(|p| p.as_str().to_owned());
|
||||||
|
assert_eq!(drained.as_deref(), Some("ab"));
|
||||||
|
assert!(agent.take_pending_post_flush().is_none());
|
||||||
|
}
|
||||||
fn make_agent() -> AgentView {
|
fn make_agent() -> AgentView {
|
||||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
AgentView::new(
|
AgentView::new(
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ impl AgentView {
|
|||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
// Kitty images outlive the dropped viewer state; clear before close.
|
// Kitty images outlive the dropped viewer state; clear before close.
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
|
||||||
let clear = PostFlush::from(overlay::clear_kitty());
|
|
||||||
let _ = clear.write_to(stderr);
|
|
||||||
});
|
|
||||||
self.image_viewer = None;
|
self.image_viewer = None;
|
||||||
self.image_load_rx = None;
|
self.image_load_rx = None;
|
||||||
// The viewer's decoded/re-encoded overlay image (tens of MB
|
// The viewer's decoded/re-encoded overlay image (tens of MB
|
||||||
@@ -539,10 +536,7 @@ impl AgentView {
|
|||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
// Kitty images outlive the dropped viewer state; clear before close.
|
// Kitty images outlive the dropped viewer state; clear before close.
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
|
||||||
let clear = PostFlush::from(overlay::clear_kitty());
|
|
||||||
let _ = clear.write_to(stderr);
|
|
||||||
});
|
|
||||||
self.video_viewer = None;
|
self.video_viewer = None;
|
||||||
// The viewer's pre-extracted frame set (~50–300 MB for a
|
// The viewer's pre-extracted frame set (~50–300 MB for a
|
||||||
// typical clip) just dropped; return the pages to the OS.
|
// typical clip) just dropped; return the pages to the OS.
|
||||||
@@ -569,10 +563,7 @@ impl AgentView {
|
|||||||
match gboom.handle_key(key) {
|
match gboom.handle_key(key) {
|
||||||
crate::gboom::GboomKeyOutcome::Close => {
|
crate::gboom::GboomKeyOutcome::Close => {
|
||||||
// Kitty images outlive the dropped game state; clear before close.
|
// Kitty images outlive the dropped game state; clear before close.
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
|
||||||
let clear = PostFlush::from(overlay::clear_kitty());
|
|
||||||
let _ = clear.write_to(stderr);
|
|
||||||
});
|
|
||||||
self.gboom = None;
|
self.gboom = None;
|
||||||
}
|
}
|
||||||
crate::gboom::GboomKeyOutcome::Changed => {}
|
crate::gboom::GboomKeyOutcome::Changed => {}
|
||||||
|
|||||||
@@ -1003,6 +1003,8 @@ pub struct AgentView {
|
|||||||
pub(crate) edit_hl: Option<crate::app::edit_highlight_worker::EditHlRuntime>,
|
pub(crate) edit_hl: Option<crate::app::edit_highlight_worker::EditHlRuntime>,
|
||||||
/// Whether any inline media is currently placed on screen.
|
/// Whether any inline media is currently placed on screen.
|
||||||
pub(crate) inline_media_active: bool,
|
pub(crate) inline_media_active: bool,
|
||||||
|
/// Escapes queued for the next frame's post-flush write (see `queue_post_flush`).
|
||||||
|
pub(crate) pending_post_flush: Option<crate::terminal::overlay::PostFlush>,
|
||||||
/// Image IDs that were placed on screen last frame. Used to detect
|
/// Image IDs that were placed on screen last frame. Used to detect
|
||||||
/// images that scrolled off and need their Kitty placements cleared.
|
/// images that scrolled off and need their Kitty placements cleared.
|
||||||
pub(crate) last_placed_ids: HashSet<u32>,
|
pub(crate) last_placed_ids: HashSet<u32>,
|
||||||
|
|||||||
@@ -344,7 +344,10 @@ impl AgentView {
|
|||||||
self.prompt.textarea.begin_undo_group();
|
self.prompt.textarea.begin_undo_group();
|
||||||
group_open = true;
|
group_open = true;
|
||||||
}
|
}
|
||||||
if matches!(self.prompt.handle_paste(&to_insert), PromptEvent::Edited) {
|
if matches!(
|
||||||
|
self.prompt.insert_dropped_path(&to_insert),
|
||||||
|
PromptEvent::Edited
|
||||||
|
) {
|
||||||
inserted_non_image = true;
|
inserted_non_image = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -605,13 +605,32 @@ impl AgentView {
|
|||||||
false,
|
false,
|
||||||
&mut Vec::new(),
|
&mut Vec::new(),
|
||||||
);
|
);
|
||||||
child_post_flush = post_flush;
|
// The child's own queued clears (inline image ids) go out before its frame escapes.
|
||||||
|
child_post_flush = match (child_view.take_pending_post_flush(), post_flush) {
|
||||||
|
(Some(mut pending), Some(frame)) => {
|
||||||
|
pending.append(frame);
|
||||||
|
Some(pending)
|
||||||
|
}
|
||||||
|
(pending, frame) => pending.or(frame),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
(None, child_post_flush)
|
(None, child_post_flush)
|
||||||
}
|
}
|
||||||
pub fn should_show_tip(&mut self) -> bool {
|
pub fn should_show_tip(&mut self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
/// Queue escapes for the frame's post-flush write: the event loop never writes stderr inline.
|
||||||
|
pub(crate) fn queue_post_flush(&mut self, escapes: crate::terminal::overlay::PostFlush) {
|
||||||
|
match self.pending_post_flush.as_mut() {
|
||||||
|
Some(pending) => pending.append(escapes),
|
||||||
|
None => self.pending_post_flush = Some(escapes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn take_pending_post_flush(
|
||||||
|
&mut self,
|
||||||
|
) -> Option<crate::terminal::overlay::PostFlush> {
|
||||||
|
self.pending_post_flush.take()
|
||||||
|
}
|
||||||
/// `area` is the screen region assigned to this agent view.
|
/// `area` is the screen region assigned to this agent view.
|
||||||
/// When a tracing overlay is visible, this is smaller than `f.area()`.
|
/// When a tracing overlay is visible, this is smaller than `f.area()`.
|
||||||
///
|
///
|
||||||
@@ -669,20 +688,17 @@ impl AgentView {
|
|||||||
|| self.active_modal.is_some())
|
|| self.active_modal.is_some())
|
||||||
{
|
{
|
||||||
self.inline_media_active = false;
|
self.inline_media_active = false;
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
let mut clears = String::new();
|
||||||
for &id in self.inline_media_ids.values() {
|
for &id in self.inline_media_ids.values() {
|
||||||
let clear = crate::terminal::image::clear_kitty_image(id);
|
clears.push_str(&crate::terminal::image::clear_kitty_image(id));
|
||||||
let _ = std::io::Write::write_all(stderr, clear.as_bytes());
|
}
|
||||||
}
|
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(clears));
|
||||||
});
|
|
||||||
self.inline_media_ids.clear();
|
self.inline_media_ids.clear();
|
||||||
self.inline_media_iterm_emitted.clear();
|
self.inline_media_iterm_emitted.clear();
|
||||||
}
|
}
|
||||||
if let Some(ref child_sid) = self.active_subagent.clone() {
|
if let Some(ref child_sid) = self.active_subagent.clone() {
|
||||||
if let Some(esc) = self.take_own_inline_media_clear_escapes() {
|
if let Some(esc) = self.take_own_inline_media_clear_escapes() {
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(esc));
|
||||||
let _ = std::io::Write::write_all(stderr, esc.as_bytes());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return self.draw_subagent_fullscreen(
|
return self.draw_subagent_fullscreen(
|
||||||
&child_sid.clone(),
|
&child_sid.clone(),
|
||||||
@@ -695,9 +711,7 @@ impl AgentView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(esc) = self.take_subagent_inline_media_clear_escapes() {
|
if let Some(esc) = self.take_subagent_inline_media_clear_escapes() {
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(esc));
|
||||||
let _ = std::io::Write::write_all(stderr, esc.as_bytes());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
let appearance = self.scrollback.appearance().clone();
|
let appearance = self.scrollback.appearance().clone();
|
||||||
let layout_cfg = &appearance.scrollback.layout;
|
let layout_cfg = &appearance.scrollback.layout;
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ impl AgentView {
|
|||||||
mermaid: None,
|
mermaid: None,
|
||||||
edit_hl: None,
|
edit_hl: None,
|
||||||
inline_media_active: false,
|
inline_media_active: false,
|
||||||
|
pending_post_flush: None,
|
||||||
last_placed_ids: HashSet::new(),
|
last_placed_ids: HashSet::new(),
|
||||||
last_terminal_size: (0, 0),
|
last_terminal_size: (0, 0),
|
||||||
terminal_size_stale: false,
|
terminal_size_stale: false,
|
||||||
@@ -733,6 +734,20 @@ impl AgentView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// The shell reported the session's effective context window (model
|
||||||
|
/// switch). The snapshot caches the previous model's window, so the
|
||||||
|
/// header would show it until the next streaming update. Returns
|
||||||
|
/// whether the snapshot changed, so the caller can request a redraw.
|
||||||
|
pub fn apply_context_window(&mut self, total: u64) -> bool {
|
||||||
|
let Some((used, cached)) = self.context_state.as_ref().map(|c| (c.used, c.total)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if cached == total {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.apply_context_used(used, total);
|
||||||
|
true
|
||||||
|
}
|
||||||
/// Record a key event to the input flight recorder.
|
/// Record a key event to the input flight recorder.
|
||||||
///
|
///
|
||||||
/// Zero heap allocations — stores raw `Copy` types in the ring buffer.
|
/// Zero heap allocations — stores raw `Copy` types in the ring buffer.
|
||||||
@@ -1262,6 +1277,36 @@ mod resolve_turn_activity_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod apply_context_window_tests {
|
||||||
|
use super::super::test_agent_view;
|
||||||
|
fn view() -> super::AgentView {
|
||||||
|
test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"))
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn reported_window_replaces_the_cached_total() {
|
||||||
|
let mut agent = view();
|
||||||
|
agent.apply_context_used(15_000, 1_000_000);
|
||||||
|
assert!(agent.apply_context_window(262_144));
|
||||||
|
let snap = agent.context_state.as_ref().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(snap.used, snap.total, snap.usage_pct, snap.free_tokens),
|
||||||
|
(15_000, 262_144, 6, 262_144 - 15_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn same_window_reports_no_change() {
|
||||||
|
let mut agent = view();
|
||||||
|
agent.apply_context_used(15_000, 1_000_000);
|
||||||
|
assert!(!agent.apply_context_window(1_000_000));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn no_snapshot_stays_none() {
|
||||||
|
let mut agent = view();
|
||||||
|
assert!(!agent.apply_context_window(262_144));
|
||||||
|
assert!(agent.context_state.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
mod status_window_tests {
|
mod status_window_tests {
|
||||||
use super::super::test_agent_view;
|
use super::super::test_agent_view;
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -954,6 +954,8 @@ pub struct AppView {
|
|||||||
/// Whether the pager uses fullscreen (alt-screen) or inline mode.
|
/// Whether the pager uses fullscreen (alt-screen) or inline mode.
|
||||||
/// Set from the resolved terminal state at startup.
|
/// Set from the resolved terminal state at startup.
|
||||||
pub(crate) screen_mode: super::ScreenMode,
|
pub(crate) screen_mode: super::ScreenMode,
|
||||||
|
/// Out-of-band escapes ride the frame queue, never an inline stderr write.
|
||||||
|
pub(crate) escape_writer: crate::render::draw::EscapeWriter,
|
||||||
/// Agent Dashboard state. `Some(_)` only when the dashboard view
|
/// Agent Dashboard state. `Some(_)` only when the dashboard view
|
||||||
/// is active (`active_view == AgentDashboard`) or recently closed.
|
/// is active (`active_view == AgentDashboard`) or recently closed.
|
||||||
/// Held outside the `ActiveView` discriminant because `DashboardState`
|
/// Held outside the `ActiveView` discriminant because `DashboardState`
|
||||||
@@ -1022,7 +1024,10 @@ impl AppView {
|
|||||||
scroll_state: MouseScrollState::default(),
|
scroll_state: MouseScrollState::default(),
|
||||||
scroll_config: ScrollConfig::from_settings(),
|
scroll_config: ScrollConfig::from_settings(),
|
||||||
appearance: AppearanceConfig::default(),
|
appearance: AppearanceConfig::default(),
|
||||||
notification_service: NotificationService::new(Default::default()),
|
notification_service: NotificationService::new(
|
||||||
|
Default::default(),
|
||||||
|
crate::render::draw::EscapeWriter::disconnected(),
|
||||||
|
),
|
||||||
pending_notification_escapes: None,
|
pending_notification_escapes: None,
|
||||||
deferred_notification: None,
|
deferred_notification: None,
|
||||||
tracing_rx: None,
|
tracing_rx: None,
|
||||||
@@ -1054,6 +1059,7 @@ impl AppView {
|
|||||||
welcome_auth_fallback_rect: None,
|
welcome_auth_fallback_rect: None,
|
||||||
auth_show_raw_url: false,
|
auth_show_raw_url: false,
|
||||||
auth_mouse_disabled: false,
|
auth_mouse_disabled: false,
|
||||||
|
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
|
||||||
session_picker_entries: None,
|
session_picker_entries: None,
|
||||||
session_picker_loading: false,
|
session_picker_loading: false,
|
||||||
session_picker_state: crate::views::picker::PickerState::with_mode(
|
session_picker_state: crate::views::picker::PickerState::with_mode(
|
||||||
@@ -3095,9 +3101,8 @@ impl AppView {
|
|||||||
.hyperlink_capabilities()
|
.hyperlink_capabilities()
|
||||||
.osc22_cursor
|
.osc22_cursor
|
||||||
{
|
{
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
self.escape_writer
|
||||||
let _ = crossterm::execute!(stderr, crate::terminal::SetDefaultCursor);
|
.emit_command(crate::terminal::SetDefaultCursor);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let want_mouse_off = self.auth_show_raw_url
|
let want_mouse_off = self.auth_show_raw_url
|
||||||
@@ -3106,17 +3111,15 @@ impl AppView {
|
|||||||
&& matches!(self.auth_state, AuthState::Authenticating { .. });
|
&& matches!(self.auth_state, AuthState::Authenticating { .. });
|
||||||
if want_mouse_off && !self.auth_mouse_disabled {
|
if want_mouse_off && !self.auth_mouse_disabled {
|
||||||
self.auth_mouse_disabled = true;
|
self.auth_mouse_disabled = true;
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
self.escape_writer
|
||||||
let _ = crossterm::execute!(stderr, crossterm::event::DisableMouseCapture);
|
.emit_command(crossterm::event::DisableMouseCapture);
|
||||||
});
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
super::win_native_selection::enable_native_selection();
|
super::win_native_selection::enable_native_selection();
|
||||||
super::MOUSE_CAPTURE_ENABLED.store(false, std::sync::atomic::Ordering::Release);
|
super::MOUSE_CAPTURE_ENABLED.store(false, std::sync::atomic::Ordering::Release);
|
||||||
} else if !want_mouse_off && self.auth_mouse_disabled {
|
} else if !want_mouse_off && self.auth_mouse_disabled {
|
||||||
self.auth_mouse_disabled = false;
|
self.auth_mouse_disabled = false;
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
self.escape_writer
|
||||||
let _ = crossterm::execute!(stderr, crossterm::event::EnableMouseCapture);
|
.emit_command(crossterm::event::EnableMouseCapture);
|
||||||
});
|
|
||||||
super::MOUSE_CAPTURE_ENABLED.store(true, std::sync::atomic::Ordering::Release);
|
super::MOUSE_CAPTURE_ENABLED.store(true, std::sync::atomic::Ordering::Release);
|
||||||
for agent in self.agents.values_mut() {
|
for agent in self.agents.values_mut() {
|
||||||
agent.set_sticky_toast_recursive(None);
|
agent.set_sticky_toast_recursive(None);
|
||||||
@@ -3450,6 +3453,8 @@ impl AppView {
|
|||||||
panel.render(full_area, f.buffer_mut());
|
panel.render(full_area, f.buffer_mut());
|
||||||
}
|
}
|
||||||
let (cursor_pos, post_flush) = result;
|
let (cursor_pos, post_flush) = result;
|
||||||
|
let post_flush =
|
||||||
|
Self::merge_post_flush(agent.take_pending_post_flush(), post_flush);
|
||||||
let has_cloud = false;
|
let has_cloud = false;
|
||||||
if has_cloud || self.import_claude_modal.is_some() {
|
if has_cloud || self.import_claude_modal.is_some() {
|
||||||
link_spans.clear();
|
link_spans.clear();
|
||||||
@@ -3505,7 +3510,7 @@ impl AppView {
|
|||||||
dashboard,
|
dashboard,
|
||||||
|inner, buf| {
|
|inner, buf| {
|
||||||
if let Some(agent) = agents.get_mut(&agent_id) {
|
if let Some(agent) = agents.get_mut(&agent_id) {
|
||||||
agent.draw(
|
let (cursor, post_flush) = agent.draw(
|
||||||
inner,
|
inner,
|
||||||
buf,
|
buf,
|
||||||
registry,
|
registry,
|
||||||
@@ -3517,6 +3522,13 @@ impl AppView {
|
|||||||
bundle_state,
|
bundle_state,
|
||||||
false,
|
false,
|
||||||
link_spans,
|
link_spans,
|
||||||
|
);
|
||||||
|
(
|
||||||
|
cursor,
|
||||||
|
Self::merge_post_flush(
|
||||||
|
agent.take_pending_post_flush(),
|
||||||
|
post_flush,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(None, None)
|
(None, None)
|
||||||
@@ -4304,7 +4316,10 @@ pub(crate) mod tests {
|
|||||||
scroll_state: MouseScrollState::default(),
|
scroll_state: MouseScrollState::default(),
|
||||||
scroll_config: ScrollConfig::default(),
|
scroll_config: ScrollConfig::default(),
|
||||||
appearance: AppearanceConfig::default(),
|
appearance: AppearanceConfig::default(),
|
||||||
notification_service: NotificationService::new(Default::default()),
|
notification_service: NotificationService::new(
|
||||||
|
Default::default(),
|
||||||
|
crate::render::draw::EscapeWriter::disconnected(),
|
||||||
|
),
|
||||||
pending_notification_escapes: None,
|
pending_notification_escapes: None,
|
||||||
deferred_notification: None,
|
deferred_notification: None,
|
||||||
tracing_rx: None,
|
tracing_rx: None,
|
||||||
@@ -4373,6 +4388,7 @@ pub(crate) mod tests {
|
|||||||
welcome_auth_fallback_rect: None,
|
welcome_auth_fallback_rect: None,
|
||||||
auth_show_raw_url: false,
|
auth_show_raw_url: false,
|
||||||
auth_mouse_disabled: false,
|
auth_mouse_disabled: false,
|
||||||
|
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
|
||||||
session_picker_entries: None,
|
session_picker_entries: None,
|
||||||
session_picker_loading: false,
|
session_picker_loading: false,
|
||||||
session_picker_state: crate::views::picker::PickerState::with_mode(
|
session_picker_state: crate::views::picker::PickerState::with_mode(
|
||||||
|
|||||||
@@ -1290,10 +1290,9 @@ pub(super) fn handle_compact_complete(
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!(agent = ?agent_id, error = %err, "Compaction failed");
|
tracing::error!(agent = ?agent_id, error = %err, "Compaction failed");
|
||||||
|
// Already sanitized by the effect layer.
|
||||||
agent.scrollback.push_block(RenderBlock::session_event(
|
agent.scrollback.push_block(RenderBlock::session_event(
|
||||||
SessionEvent::CompactionFailed {
|
SessionEvent::CompactionFailed { error: err.clone() },
|
||||||
error: String::new(),
|
|
||||||
},
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ fn format_cron_prompt(prompt: &str, task_id: &str, human_schedule: &str) -> Stri
|
|||||||
kigi_tools::reminders::format_scheduled_task_prompt(prompt, task_id, human_schedule)
|
kigi_tools::reminders::format_scheduled_task_prompt(prompt, task_id, human_schedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `/compact <instructions>` text from a queued command; a queue edit may leave leading whitespace.
|
||||||
|
pub(super) fn compact_user_context(text: &str) -> Option<String> {
|
||||||
|
let inv = crate::slash::parse_invocation(text.trim_start())?;
|
||||||
|
let args = inv.args.trim();
|
||||||
|
(inv.token == "compact" && !args.is_empty()).then(|| args.to_string())
|
||||||
|
}
|
||||||
/// Try to send the next queued entry (prompt, command, bash, or cron) if the agent is idle.
|
/// Try to send the next queued entry (prompt, command, bash, or cron) if the agent is idle.
|
||||||
///
|
///
|
||||||
/// Called after enqueue operations and task completions to advance the queue.
|
/// Called after enqueue operations and task completions to advance the queue.
|
||||||
@@ -392,10 +398,12 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
|
|||||||
agent.end_work_announced = false;
|
agent.end_work_announced = false;
|
||||||
agent.session.start_command(AgentCommand::Compact);
|
agent.session.start_command(AgentCommand::Compact);
|
||||||
agent.turn_started_at = Some(Instant::now());
|
agent.turn_started_at = Some(Instant::now());
|
||||||
|
let user_context = compact_user_context(&queued.text);
|
||||||
|
|
||||||
vec![Effect::Compact {
|
vec![Effect::Compact {
|
||||||
agent_id,
|
agent_id,
|
||||||
session_id,
|
session_id,
|
||||||
|
user_context,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
QueueEntryKind::BashCommand => {
|
QueueEntryKind::BashCommand => {
|
||||||
|
|||||||
@@ -978,13 +978,13 @@ pub(in crate::app::dispatch) fn handle_switch_model_complete(
|
|||||||
agent_id: AgentId,
|
agent_id: AgentId,
|
||||||
model_id: acp::ModelId,
|
model_id: acp::ModelId,
|
||||||
effort: Option<ReasoningEffort>,
|
effort: Option<ReasoningEffort>,
|
||||||
result: Result<(), SwitchModelError>,
|
result: Result<Option<u64>, SwitchModelError>,
|
||||||
prev_model_id: Option<acp::ModelId>,
|
prev_model_id: Option<acp::ModelId>,
|
||||||
) -> Vec<Effect> {
|
) -> Vec<Effect> {
|
||||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||||
agent.session.model_switch_pending = false;
|
agent.session.model_switch_pending = false;
|
||||||
let mut effects = match result {
|
let mut effects = match result {
|
||||||
Ok(()) => {
|
Ok(context_window) => {
|
||||||
agent.session.user_model_preference = Some(model_id.clone());
|
agent.session.user_model_preference = Some(model_id.clone());
|
||||||
let display_name = agent
|
let display_name = agent
|
||||||
.session
|
.session
|
||||||
@@ -996,6 +996,9 @@ pub(in crate::app::dispatch) fn handle_switch_model_complete(
|
|||||||
let prev_model = agent.session.models.current.clone();
|
let prev_model = agent.session.models.current.clone();
|
||||||
let prev_effort = agent.session.models.reasoning_effort;
|
let prev_effort = agent.session.models.reasoning_effort;
|
||||||
agent.session.models.set_current(model_id.clone(), effort);
|
agent.session.models.set_current(model_id.clone(), effort);
|
||||||
|
if let Some(total) = context_window {
|
||||||
|
agent.apply_context_window(total);
|
||||||
|
}
|
||||||
let resolved_effort = agent.session.models.reasoning_effort;
|
let resolved_effort = agent.session.models.reasoning_effort;
|
||||||
let unchanged =
|
let unchanged =
|
||||||
prev_model.as_ref() == Some(&model_id) && prev_effort == resolved_effort;
|
prev_model.as_ref() == Some(&model_id) && prev_effort == resolved_effort;
|
||||||
|
|||||||
@@ -506,13 +506,14 @@ pub(in crate::app::dispatch) fn dispatch_toggle_mouse_capture(app: &mut AppView)
|
|||||||
"active_view": format!("{:?}", app.active_view),
|
"active_view": format!("{:?}", app.active_view),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
// Through the writer queue, never inline: see EscapeWriter.
|
||||||
let _ = if enable {
|
if enable {
|
||||||
crossterm::execute!(stderr, crossterm::event::EnableMouseCapture)
|
app.escape_writer
|
||||||
} else {
|
.emit_command(crossterm::event::EnableMouseCapture);
|
||||||
crossterm::execute!(stderr, crossterm::event::DisableMouseCapture)
|
} else {
|
||||||
};
|
app.escape_writer
|
||||||
});
|
.emit_command(crossterm::event::DisableMouseCapture);
|
||||||
|
}
|
||||||
// On legacy conhost, DisableMouseCapture restores the *pre-capture* stdin
|
// On legacy conhost, DisableMouseCapture restores the *pre-capture* stdin
|
||||||
// mode, which may itself have QuickEdit off (per-window profile or a
|
// mode, which may itself have QuickEdit off (per-window profile or a
|
||||||
// stale mode from a crashed run) — assert it so "mouse off" actually
|
// stale mode from a crashed run) — assert it so "mouse off" actually
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
|||||||
None,
|
None,
|
||||||
wrap_text.as_deref(),
|
wrap_text.as_deref(),
|
||||||
None,
|
None,
|
||||||
|
&app.escape_writer,
|
||||||
);
|
);
|
||||||
let effects = drain_clipboard_target(&target, app);
|
let effects = drain_clipboard_target(&target, app);
|
||||||
maybe_show_x11_primary_paste_hint(
|
maybe_show_x11_primary_paste_hint(
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ fn test_app() -> AppView {
|
|||||||
scroll_state: crate::input::mouse::MouseScrollState::default(),
|
scroll_state: crate::input::mouse::MouseScrollState::default(),
|
||||||
scroll_config: crate::input::mouse::ScrollConfig::default(),
|
scroll_config: crate::input::mouse::ScrollConfig::default(),
|
||||||
appearance: crate::appearance::AppearanceConfig::default(),
|
appearance: crate::appearance::AppearanceConfig::default(),
|
||||||
notification_service: crate::notifications::NotificationService::new(Default::default()),
|
notification_service: crate::notifications::NotificationService::new(
|
||||||
|
Default::default(),
|
||||||
|
crate::render::draw::EscapeWriter::disconnected(),
|
||||||
|
),
|
||||||
pending_notification_escapes: None,
|
pending_notification_escapes: None,
|
||||||
deferred_notification: None,
|
deferred_notification: None,
|
||||||
tracing_rx: None,
|
tracing_rx: None,
|
||||||
@@ -153,6 +156,7 @@ fn test_app() -> AppView {
|
|||||||
welcome_auth_fallback_rect: None,
|
welcome_auth_fallback_rect: None,
|
||||||
auth_show_raw_url: false,
|
auth_show_raw_url: false,
|
||||||
auth_mouse_disabled: false,
|
auth_mouse_disabled: false,
|
||||||
|
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
|
||||||
session_picker_entries: None,
|
session_picker_entries: None,
|
||||||
session_picker_loading: false,
|
session_picker_loading: false,
|
||||||
session_picker_state: crate::views::picker::PickerState::with_mode(
|
session_picker_state: crate::views::picker::PickerState::with_mode(
|
||||||
|
|||||||
@@ -1789,7 +1789,7 @@ fn switch_model_holds_prompt_until_complete() {
|
|||||||
agent_id: id,
|
agent_id: id,
|
||||||
model_id,
|
model_id,
|
||||||
effort: None,
|
effort: None,
|
||||||
result: Ok(()),
|
result: Ok(None),
|
||||||
prev_model_id: None,
|
prev_model_id: None,
|
||||||
}),
|
}),
|
||||||
&mut app,
|
&mut app,
|
||||||
@@ -1810,7 +1810,13 @@ fn slash_compact_enqueues_command() {
|
|||||||
let effects = dispatch(Action::SendPrompt("/compact".into()), &mut app);
|
let effects = dispatch(Action::SendPrompt("/compact".into()), &mut app);
|
||||||
// /compact enqueues as Command and drains immediately (agent was idle).
|
// /compact enqueues as Command and drains immediately (agent was idle).
|
||||||
assert_eq!(effects.len(), 1);
|
assert_eq!(effects.len(), 1);
|
||||||
assert!(matches!(&effects[0], Effect::Compact { .. }));
|
assert!(matches!(
|
||||||
|
&effects[0],
|
||||||
|
Effect::Compact {
|
||||||
|
user_context: None,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
// Prompt should be cleared.
|
// Prompt should be cleared.
|
||||||
assert!(app.agents[&id].prompt.text().is_empty());
|
assert!(app.agents[&id].prompt.text().is_empty());
|
||||||
}
|
}
|
||||||
@@ -1853,6 +1859,19 @@ fn palette_dispatch_preserves_prompt_draft() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An edited queue row keeps its text verbatim, so leading whitespace must not drop the instructions.
|
||||||
|
#[test]
|
||||||
|
fn compact_user_context_survives_leading_whitespace_from_a_queue_edit() {
|
||||||
|
use crate::app::dispatch::queue::compact_user_context;
|
||||||
|
assert_eq!(
|
||||||
|
compact_user_context(" /compact focus on auth").as_deref(),
|
||||||
|
Some("focus on auth")
|
||||||
|
);
|
||||||
|
assert_eq!(compact_user_context("\n/compact x ").as_deref(), Some("x"));
|
||||||
|
assert_eq!(compact_user_context("/compact"), None);
|
||||||
|
assert_eq!(compact_user_context("/other args"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slash_compact_with_context_enqueues_command() {
|
fn slash_compact_with_context_enqueues_command() {
|
||||||
let mut app = test_app_with_agent();
|
let mut app = test_app_with_agent();
|
||||||
@@ -1861,7 +1880,13 @@ fn slash_compact_with_context_enqueues_command() {
|
|||||||
&mut app,
|
&mut app,
|
||||||
);
|
);
|
||||||
assert_eq!(effects.len(), 1);
|
assert_eq!(effects.len(), 1);
|
||||||
assert!(matches!(&effects[0], Effect::Compact { .. }));
|
assert!(matches!(
|
||||||
|
&effects[0],
|
||||||
|
Effect::Compact {
|
||||||
|
user_context: Some(ctx),
|
||||||
|
..
|
||||||
|
} if ctx == "focus on auth"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ fn model_switch_pending_resets_correctly_across_success_and_failure() {
|
|||||||
agent_id: id,
|
agent_id: id,
|
||||||
model_id: model_a,
|
model_id: model_a,
|
||||||
effort: None,
|
effort: None,
|
||||||
result: Ok(()),
|
result: Ok(None),
|
||||||
prev_model_id: None,
|
prev_model_id: None,
|
||||||
}),
|
}),
|
||||||
&mut app,
|
&mut app,
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
|
|||||||
agent_id: id,
|
agent_id: id,
|
||||||
model_id: model_id.clone(),
|
model_id: model_id.clone(),
|
||||||
effort: None,
|
effort: None,
|
||||||
result: Ok(()),
|
result: Ok(None),
|
||||||
prev_model_id: None,
|
prev_model_id: None,
|
||||||
}),
|
}),
|
||||||
&mut app,
|
&mut app,
|
||||||
@@ -554,6 +554,96 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bare `/model <name>` path: `SetDefaultModel` moves `models.current`
|
||||||
|
/// before the ACP round trip; the completion then installs the effective
|
||||||
|
/// window the shell reported, so the header repaints at once.
|
||||||
|
#[test]
|
||||||
|
fn switch_model_complete_installs_the_reported_context_window() {
|
||||||
|
let mut app = test_app_with_agent();
|
||||||
|
let id = AgentId(0);
|
||||||
|
let model = |slug: &str, window: u64| {
|
||||||
|
let mid = acp::ModelId::new(std::sync::Arc::from(slug));
|
||||||
|
let info = acp::ModelInfo::new(mid.clone(), slug.to_string()).meta(
|
||||||
|
serde_json::json!({ "totalContextTokens": window })
|
||||||
|
.as_object()
|
||||||
|
.cloned(),
|
||||||
|
);
|
||||||
|
(mid, info)
|
||||||
|
};
|
||||||
|
let (wide, wide_info) = model("wide", 1_000_000);
|
||||||
|
let (narrow, narrow_info) = model("narrow", 262_144);
|
||||||
|
{
|
||||||
|
let agent = app.agents.get_mut(&id).unwrap();
|
||||||
|
agent
|
||||||
|
.session
|
||||||
|
.models
|
||||||
|
.available
|
||||||
|
.insert(wide.clone(), wide_info);
|
||||||
|
agent
|
||||||
|
.session
|
||||||
|
.models
|
||||||
|
.available
|
||||||
|
.insert(narrow.clone(), narrow_info);
|
||||||
|
agent.session.models.set_current(wide.clone(), None);
|
||||||
|
agent.apply_context_used(15_000, 1_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let effects = dispatch(Action::SetDefaultModel(narrow.clone()), &mut app);
|
||||||
|
assert!(
|
||||||
|
effects
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, Effect::SwitchModel { .. }))
|
||||||
|
);
|
||||||
|
assert!(app.agents[&id].session.model_switch_pending);
|
||||||
|
assert_eq!(
|
||||||
|
app.agents[&id].context_state.as_ref().unwrap().total,
|
||||||
|
1_000_000
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
Action::TaskComplete(TaskResult::SwitchModelComplete {
|
||||||
|
agent_id: id,
|
||||||
|
model_id: narrow,
|
||||||
|
effort: None,
|
||||||
|
result: Ok(Some(262_144)),
|
||||||
|
prev_model_id: Some(wide),
|
||||||
|
}),
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
|
||||||
|
let agent = app.agents.get_mut(&id).unwrap();
|
||||||
|
let snap = agent.context_state.as_ref().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(snap.used, snap.total, snap.usage_pct),
|
||||||
|
(15_000, 262_144, 6)
|
||||||
|
);
|
||||||
|
let area = ratatui::layout::Rect::new(0, 0, 100, 40);
|
||||||
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||||
|
agent.draw(
|
||||||
|
area,
|
||||||
|
&mut buf,
|
||||||
|
&crate::actions::ActionRegistry::defaults(),
|
||||||
|
&mut crate::scrollback::render::ScratchBuffer::new(),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
&crate::app::bundle::BundleState::default(),
|
||||||
|
false,
|
||||||
|
&mut Vec::new(),
|
||||||
|
);
|
||||||
|
let screen: String = (0..area.height)
|
||||||
|
.map(|y| {
|
||||||
|
(0..area.width)
|
||||||
|
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(screen.contains("15K / 262K"), "header: {screen:?}");
|
||||||
|
assert!(!screen.contains("1.0M"), "stale window: {screen:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn switch_model_complete_skips_message_and_persist_when_unchanged() {
|
fn switch_model_complete_skips_message_and_persist_when_unchanged() {
|
||||||
let mut app = test_app_with_agent();
|
let mut app = test_app_with_agent();
|
||||||
@@ -575,7 +665,7 @@ fn switch_model_complete_skips_message_and_persist_when_unchanged() {
|
|||||||
agent_id: id,
|
agent_id: id,
|
||||||
model_id: model_id.clone(),
|
model_id: model_id.clone(),
|
||||||
effort: None,
|
effort: None,
|
||||||
result: Ok(()),
|
result: Ok(None),
|
||||||
prev_model_id: None,
|
prev_model_id: None,
|
||||||
}),
|
}),
|
||||||
&mut app,
|
&mut app,
|
||||||
@@ -630,7 +720,7 @@ fn switch_model_complete_persists_resolved_effort_from_catalog_meta() {
|
|||||||
model_id: model_id.clone(),
|
model_id: model_id.clone(),
|
||||||
// user typed `/model Blackbox 4.7` with no effort
|
// user typed `/model Blackbox 4.7` with no effort
|
||||||
effort: None,
|
effort: None,
|
||||||
result: Ok(()),
|
result: Ok(None),
|
||||||
prev_model_id: None,
|
prev_model_id: None,
|
||||||
}),
|
}),
|
||||||
&mut app,
|
&mut app,
|
||||||
@@ -696,7 +786,7 @@ fn switch_to_non_reasoning_model_clears_persisted_effort() {
|
|||||||
agent_id: id,
|
agent_id: id,
|
||||||
model_id: model_id.clone(),
|
model_id: model_id.clone(),
|
||||||
effort: None,
|
effort: None,
|
||||||
result: Ok(()),
|
result: Ok(None),
|
||||||
prev_model_id: None,
|
prev_model_id: None,
|
||||||
}),
|
}),
|
||||||
&mut app,
|
&mut app,
|
||||||
@@ -930,7 +1020,7 @@ fn same_agent_type_switch_no_modal() {
|
|||||||
agent_id: id,
|
agent_id: id,
|
||||||
model_id: model_b.clone(),
|
model_id: model_b.clone(),
|
||||||
effort: None,
|
effort: None,
|
||||||
result: Ok(()),
|
result: Ok(None),
|
||||||
prev_model_id: None,
|
prev_model_id: None,
|
||||||
}),
|
}),
|
||||||
&mut app,
|
&mut app,
|
||||||
@@ -969,7 +1059,7 @@ fn switch_model_pending_lifecycle() {
|
|||||||
agent_id: id,
|
agent_id: id,
|
||||||
model_id,
|
model_id,
|
||||||
effort: None,
|
effort: None,
|
||||||
result: Ok(()),
|
result: Ok(None),
|
||||||
prev_model_id: None,
|
prev_model_id: None,
|
||||||
}),
|
}),
|
||||||
&mut app,
|
&mut app,
|
||||||
|
|||||||
@@ -1388,13 +1388,18 @@ pub(crate) fn execute(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Effect::Compact { agent_id, session_id } => {
|
Effect::Compact {
|
||||||
|
agent_id,
|
||||||
|
session_id,
|
||||||
|
user_context,
|
||||||
|
} => {
|
||||||
let tx = acp_tx.clone();
|
let tx = acp_tx.clone();
|
||||||
tasks
|
tasks
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
let params = serde_json::json!(
|
let params = serde_json::json!({
|
||||||
{ "sessionId" : session_id.0.to_string(), }
|
"sessionId": session_id.0.to_string(),
|
||||||
);
|
"userContext": user_context,
|
||||||
|
});
|
||||||
let req = acp::ExtRequest::new(
|
let req = acp::ExtRequest::new(
|
||||||
"kigi/compact_conversation",
|
"kigi/compact_conversation",
|
||||||
serde_json::value::to_raw_value(¶ms)
|
serde_json::value::to_raw_value(¶ms)
|
||||||
@@ -1404,9 +1409,14 @@ pub(crate) fn execute(
|
|||||||
let result = acp_send(req, &tx).await;
|
let result = acp_send(req, &tx).await;
|
||||||
TaskResult::CompactComplete {
|
TaskResult::CompactComplete {
|
||||||
agent_id,
|
agent_id,
|
||||||
result: result
|
// The decoded first line only: diagnostic lines (request URLs) never reach the banner.
|
||||||
.map(|_| ())
|
result: result.map(|_| ()).map_err(|e| {
|
||||||
.map_err(|e| sanitize_user_error(&e.to_string())),
|
sanitize_user_error(
|
||||||
|
&kigi_shell::sampling::error::user_facing_compact_error(
|
||||||
|
&kigi_shell::sampling::error::acp_error_message(&e),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1587,7 +1597,12 @@ pub(crate) fn execute(
|
|||||||
.meta(meta);
|
.meta(meta);
|
||||||
let result = acp_send(req, &tx)
|
let result = acp_send(req, &tx)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|resp| {
|
||||||
|
resp.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get("totalContextTokens"))
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
})
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
use kigi_shell::agent::config::ModelSwitchIncompatibleAgentError;
|
use kigi_shell::agent::config::ModelSwitchIncompatibleAgentError;
|
||||||
if let Some(typed) = ModelSwitchIncompatibleAgentError::from_acp_error(
|
if let Some(typed) = ModelSwitchIncompatibleAgentError::from_acp_error(
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ fn suspend_for_child(
|
|||||||
reader_parked: &std::sync::atomic::AtomicBool,
|
reader_parked: &std::sync::atomic::AtomicBool,
|
||||||
input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crossterm::event::Event>,
|
input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crossterm::event::Event>,
|
||||||
run_child: impl FnOnce(),
|
run_child: impl FnOnce(),
|
||||||
) -> Option<(u16, u16)> {
|
) -> std::io::Result<Option<(u16, u16)>> {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
// Pause the reader thread, then wait for a FRESH park (reader provably out of
|
// Pause the reader thread, then wait for a FRESH park (reader provably out of
|
||||||
// crossterm) so the main thread is the sole poll/read caller; bounded so a
|
// crossterm) so the main thread is the sole poll/read caller; bounded so a
|
||||||
@@ -237,8 +237,13 @@ fn suspend_for_child(
|
|||||||
// fullscreen, before LeaveAlternateScreen below — mirroring teardown's
|
// fullscreen, before LeaveAlternateScreen below — mirroring teardown's
|
||||||
// "no late frame after LeaveAlternateScreen" drain). Bounded like the
|
// "no late frame after LeaveAlternateScreen" drain). Bounded like the
|
||||||
// reader park so a wedged pty can't hang the suspend.
|
// reader park so a wedged pty can't hang the suspend.
|
||||||
|
// The alt-screen writes below take the stderr lock a parked writer still holds: abort instead.
|
||||||
if !writer_sync.wait_drained(Duration::from_millis(750)) {
|
if !writer_sync.wait_drained(Duration::from_millis(750)) {
|
||||||
tracing::warn!("suspend: frame writer not drained within 750ms; proceeding");
|
input_paused.store(false, Ordering::Release);
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::TimedOut,
|
||||||
|
"terminal writer did not drain before suspend",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// Pre-child cursor probe (minimal only — minimal's startup already proved
|
// Pre-child cursor probe (minimal only — minimal's startup already proved
|
||||||
// this terminal answers CPR). Reader is parked, so the reply is ours.
|
// this terminal answers CPR). Reader is parked, so the reply is ours.
|
||||||
@@ -274,7 +279,7 @@ fn suspend_for_child(
|
|||||||
// lands in the channel, not the tty.
|
// lands in the channel, not the tty.
|
||||||
while input_rx.try_recv().is_ok() {}
|
while input_rx.try_recv().is_ok() {}
|
||||||
input_paused.store(false, Ordering::Release);
|
input_paused.store(false, Ordering::Release);
|
||||||
moved_cursor
|
Ok(moved_cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the inline/minimal live region after a tty-taking child exited.
|
/// Restore the inline/minimal live region after a tty-taking child exited.
|
||||||
@@ -351,7 +356,7 @@ fn run_pending_suspends(
|
|||||||
.or_else(|_| std::env::var("EDITOR"))
|
.or_else(|_| std::env::var("EDITOR"))
|
||||||
.unwrap_or_else(|_| "vi".to_string());
|
.unwrap_or_else(|_| "vi".to_string());
|
||||||
let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone();
|
let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone();
|
||||||
let moved_cursor = suspend_for_child(
|
let moved_cursor = match suspend_for_child(
|
||||||
app.screen_mode,
|
app.screen_mode,
|
||||||
&writer_sync,
|
&writer_sync,
|
||||||
input_paused,
|
input_paused,
|
||||||
@@ -360,7 +365,15 @@ fn run_pending_suspends(
|
|||||||
|| {
|
|| {
|
||||||
let _ = std::process::Command::new(&editor).arg(&path).status();
|
let _ = std::process::Command::new(&editor).arg(&path).status();
|
||||||
},
|
},
|
||||||
);
|
) {
|
||||||
|
Ok(moved) => moved,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "editor suspend skipped");
|
||||||
|
app.pending_agents_modal_refresh = None;
|
||||||
|
app.show_toast("Editor not opened: the terminal is not draining output");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(tab) = app.pending_agents_modal_refresh.take()
|
if let Some(tab) = app.pending_agents_modal_refresh.take()
|
||||||
&& let ActiveView::Agent(id) = app.active_view
|
&& let ActiveView::Agent(id) = app.active_view
|
||||||
&& let Some(agent) = app.agents.get_mut(&id)
|
&& let Some(agent) = app.agents.get_mut(&id)
|
||||||
@@ -387,7 +400,7 @@ fn run_pending_suspends(
|
|||||||
.filter(|p| !p.trim().is_empty())
|
.filter(|p| !p.trim().is_empty())
|
||||||
.unwrap_or_else(|| "less".to_string());
|
.unwrap_or_else(|| "less".to_string());
|
||||||
let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone();
|
let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone();
|
||||||
let moved_cursor = suspend_for_child(
|
let moved_cursor = match suspend_for_child(
|
||||||
app.screen_mode,
|
app.screen_mode,
|
||||||
&writer_sync,
|
&writer_sync,
|
||||||
input_paused,
|
input_paused,
|
||||||
@@ -431,7 +444,15 @@ fn run_pending_suspends(
|
|||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
) {
|
||||||
|
Ok(moved) => moved,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "pager suspend skipped");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
app.show_toast("Transcript not opened: the terminal is not draining output");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
// The pager owned the screen; re-anchor if it printed inline (cat) and
|
// The pager owned the screen; re-anchor if it printed inline (cat) and
|
||||||
// repaint the full viewport rather than diffing against a screen state
|
// repaint the full viewport rather than diffing against a screen state
|
||||||
@@ -492,6 +513,7 @@ pub(crate) async fn run(
|
|||||||
// poll the leader roster (see the roster-poll arm below).
|
// poll the leader roster (see the roster-poll arm below).
|
||||||
app.leader_mode = connection.leader_status_rx.is_some();
|
app.leader_mode = connection.leader_status_rx.is_some();
|
||||||
app.screen_mode = term_state.screen_mode;
|
app.screen_mode = term_state.screen_mode;
|
||||||
|
app.escape_writer = terminal.backend_mut().writer().escape_writer();
|
||||||
// Agent/dashboard prompts pick the mode up at their creation sites
|
// Agent/dashboard prompts pick the mode up at their creation sites
|
||||||
// (`apply_app_scoped_gates` / `ensure_dashboard_state`); the welcome prompt
|
// (`apply_app_scoped_gates` / `ensure_dashboard_state`); the welcome prompt
|
||||||
// already exists, so inject here.
|
// already exists, so inject here.
|
||||||
@@ -737,12 +759,15 @@ pub(crate) async fn run(
|
|||||||
cursor: compat.cursor.sessions,
|
cursor: compat.cursor.sessions,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load notification config from [ui.notifications] in config.toml.
|
// Load notification config from [ui.notifications] in config.toml; the
|
||||||
if let Some(ref raw) = effective_config {
|
// service always gets the live escape writer, never AppView::new's stub.
|
||||||
app.notification_service = crate::notifications::NotificationService::new(
|
app.notification_service = crate::notifications::NotificationService::new(
|
||||||
crate::notifications::load_notification_config(raw),
|
effective_config
|
||||||
);
|
.as_ref()
|
||||||
}
|
.map(crate::notifications::load_notification_config)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
app.escape_writer.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
// Full layered resolve (env/requirements/remote may beat plain `[ui]`).
|
// Full layered resolve (env/requirements/remote may beat plain `[ui]`).
|
||||||
crate::appearance::cache::set_show_thinking_blocks(
|
crate::appearance::cache::set_show_thinking_blocks(
|
||||||
@@ -1378,7 +1403,7 @@ pub(crate) async fn run(
|
|||||||
let want_gboom_keyboard = app.gboom_active();
|
let want_gboom_keyboard = app.gboom_active();
|
||||||
if want_gboom_keyboard {
|
if want_gboom_keyboard {
|
||||||
if !gboom_keyboard_pushed {
|
if !gboom_keyboard_pushed {
|
||||||
super::push_gboom_keyboard_flags();
|
super::push_gboom_keyboard_flags(&app.escape_writer);
|
||||||
gboom_keyboard_pushed = true;
|
gboom_keyboard_pushed = true;
|
||||||
}
|
}
|
||||||
// Only the active game receives release events; any other open
|
// Only the active game receives release events; any other open
|
||||||
@@ -1386,7 +1411,7 @@ pub(crate) async fn run(
|
|||||||
// no key down when reopened after a tab/view switch.
|
// no key down when reopened after a tab/view switch.
|
||||||
app.gboom_release_backgrounded_games();
|
app.gboom_release_backgrounded_games();
|
||||||
} else if gboom_keyboard_pushed {
|
} else if gboom_keyboard_pushed {
|
||||||
super::pop_gboom_keyboard_flags();
|
super::pop_gboom_keyboard_flags(&app.escape_writer);
|
||||||
gboom_keyboard_pushed = false;
|
gboom_keyboard_pushed = false;
|
||||||
// No game is the active input target now (switched to a non-game
|
// No game is the active input target now (switched to a non-game
|
||||||
// view); clear every game's holds for the same reason.
|
// view); clear every game's holds for the same reason.
|
||||||
|
|||||||
@@ -290,6 +290,27 @@ mod tests {
|
|||||||
agent
|
agent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The previous-message editor is its own paste route; separators normalize there too.
|
||||||
|
#[test]
|
||||||
|
fn inline_edit_paste_normalizes_line_separators() {
|
||||||
|
let mut agent = agent_with_prompt();
|
||||||
|
assert!(agent.enter_inline_edit(0));
|
||||||
|
let registry = crate::actions::ActionRegistry::defaults();
|
||||||
|
agent.handle_input(
|
||||||
|
&crossterm::event::Event::Paste("a\u{2028}b\u{2029}c".into()),
|
||||||
|
®istry,
|
||||||
|
);
|
||||||
|
let text = agent
|
||||||
|
.inline_edit
|
||||||
|
.as_ref()
|
||||||
|
.expect("editing state")
|
||||||
|
.textarea
|
||||||
|
.text()
|
||||||
|
.to_owned();
|
||||||
|
assert!(text.contains("a\nb\nc"), "{text:?}");
|
||||||
|
assert!(!text.contains('\u{2028}'), "{text:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// Entering edit mode on a plain user prompt captures the original text,
|
/// Entering edit mode on a plain user prompt captures the original text,
|
||||||
/// resolves the rewind target, and selects the entry.
|
/// resolves the rewind target, and selects the entry.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -73,23 +73,19 @@ static GBOOM_KEYBOARD_PUSHED: AtomicBool = AtomicBool::new(false);
|
|||||||
/// release events — required to track several keys held at once. No-op
|
/// release events — required to track several keys held at once. No-op
|
||||||
/// unless the Kitty keyboard protocol is active. Balanced by
|
/// unless the Kitty keyboard protocol is active. Balanced by
|
||||||
/// [`pop_gboom_keyboard_flags`] (and by `restore_terminal` on teardown).
|
/// [`pop_gboom_keyboard_flags`] (and by `restore_terminal` on teardown).
|
||||||
pub(crate) fn push_gboom_keyboard_flags() {
|
pub(crate) fn push_gboom_keyboard_flags(writer: &crate::render::draw::EscapeWriter) {
|
||||||
if !kitty_flags_pushed() || GBOOM_KEYBOARD_PUSHED.swap(true, Ordering::AcqRel) {
|
if !kitty_flags_pushed() || GBOOM_KEYBOARD_PUSHED.swap(true, Ordering::AcqRel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let flags = event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
let flags = event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
||||||
| event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
| event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
||||||
| event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
|
| event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
writer.emit_command(event::PushKeyboardEnhancementFlags(flags));
|
||||||
let _ = execute!(stderr, event::PushKeyboardEnhancementFlags(flags));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
/// Pop the extra keyboard layer pushed by [`push_gboom_keyboard_flags`].
|
/// Pop the extra keyboard layer pushed by [`push_gboom_keyboard_flags`].
|
||||||
pub(crate) fn pop_gboom_keyboard_flags() {
|
pub(crate) fn pop_gboom_keyboard_flags(writer: &crate::render::draw::EscapeWriter) {
|
||||||
if GBOOM_KEYBOARD_PUSHED.swap(false, Ordering::AcqRel) {
|
if GBOOM_KEYBOARD_PUSHED.swap(false, Ordering::AcqRel) {
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
writer.emit_command(event::PopKeyboardEnhancementFlags);
|
||||||
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Tracks whether mouse capture (the five DEC modes enabled by
|
/// Tracks whether mouse capture (the five DEC modes enabled by
|
||||||
@@ -175,6 +171,7 @@ pub(crate) const MOUSE_OFF_HINT_PROMPT: &str =
|
|||||||
/// `write()` to stderr / the pty fd, keeping the tokio event loop free
|
/// `write()` to stderr / the pty fd, keeping the tokio event loop free
|
||||||
/// from pty back-pressure (e.g. when Ghostty is busy with another pane).
|
/// from pty back-pressure (e.g. when Ghostty is busy with another pane).
|
||||||
pub use crate::render::draw::PagerTerminal;
|
pub use crate::render::draw::PagerTerminal;
|
||||||
|
use crate::render::draw::WriterJoin;
|
||||||
/// Whether the pager uses the alternate screen (fullscreen) or stays inline.
|
/// Whether the pager uses the alternate screen (fullscreen) or stays inline.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum ScreenMode {
|
pub(crate) enum ScreenMode {
|
||||||
@@ -305,14 +302,19 @@ fn resolve_hunk_tracker_mode(
|
|||||||
/// pager skips the welcome screen and immediately loads that session (replaying
|
/// pager skips the welcome screen and immediately loads that session (replaying
|
||||||
/// its history). Sessions not found locally are restored from remote storage.
|
/// its history). Sessions not found locally are restored from remote storage.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` when the user accepted a pending update. The caller
|
/// How the pager exited: whether the user accepted a pending update (the
|
||||||
/// should print a message telling the user to relaunch `kigi`.
|
/// caller relaunches `kigi`) and whether the terminal was still reading at
|
||||||
|
/// teardown (when not, further terminal output must be skipped).
|
||||||
|
pub struct RunExit {
|
||||||
|
pub quit_for_update: bool,
|
||||||
|
pub terminal_reading: bool,
|
||||||
|
}
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
args: PagerArgs,
|
args: PagerArgs,
|
||||||
bg_update_rx: Option<
|
bg_update_rx: Option<
|
||||||
tokio::sync::oneshot::Receiver<Option<kigi_update::auto_update::UpdateAvailable>>,
|
tokio::sync::oneshot::Receiver<Option<kigi_update::auto_update::UpdateAvailable>>,
|
||||||
>,
|
>,
|
||||||
) -> anyhow::Result<bool> {
|
) -> anyhow::Result<RunExit> {
|
||||||
kigi_tty_utils::redirect_native_stderr();
|
kigi_tty_utils::redirect_native_stderr();
|
||||||
let screen_mode_override = screen_mode_relaunch::take_screen_mode_env_override();
|
let screen_mode_override = screen_mode_relaunch::take_screen_mode_env_override();
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
@@ -580,33 +582,58 @@ pub async fn run(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
crate::unified_log::flush_blocking().await;
|
crate::unified_log::flush_blocking().await;
|
||||||
let _ = restore_terminal(terminal, writer_thread, screen_mode);
|
// After a timed-out writer join the terminal is not reading: exit hints would block on stderr.
|
||||||
|
let terminal_reading = !matches!(
|
||||||
|
restore_terminal(terminal, writer_thread, screen_mode),
|
||||||
|
Ok(WriterJoin::TimedOut)
|
||||||
|
);
|
||||||
cancel.cancel();
|
cancel.cancel();
|
||||||
kigi_tty_utils::global_process_scope().kill_all();
|
kigi_tty_utils::global_process_scope().kill_all();
|
||||||
match result {
|
match result {
|
||||||
Ok(run_result) => {
|
Ok(run_result) => {
|
||||||
if run_result.quit_for_update {
|
if run_result.quit_for_update {
|
||||||
return Ok(true);
|
return Ok(RunExit {
|
||||||
|
quit_for_update: true,
|
||||||
|
terminal_reading,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if let Some(relaunch) = run_result.relaunch.as_ref() {
|
if let Some(relaunch) = run_result.relaunch.as_ref() {
|
||||||
|
if !terminal_reading {
|
||||||
|
// The replacement would inherit a silenced stderr and never show.
|
||||||
|
tracing::warn!("screen-mode relaunch skipped: the terminal stopped reading");
|
||||||
|
return Ok(RunExit {
|
||||||
|
quit_for_update: false,
|
||||||
|
terminal_reading,
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Err(e) = screen_mode_relaunch::exec_screen_mode_relaunch(
|
if let Err(e) = screen_mode_relaunch::exec_screen_mode_relaunch(
|
||||||
&relaunch.session_id,
|
&relaunch.session_id,
|
||||||
relaunch.minimal,
|
relaunch.minimal,
|
||||||
) {
|
) {
|
||||||
tracing::error!(error = % e, "screen-mode relaunch failed");
|
tracing::error!(error = % e, "screen-mode relaunch failed");
|
||||||
print_relaunch_failure_hint(
|
if terminal_reading {
|
||||||
&e,
|
print_relaunch_failure_hint(
|
||||||
&relaunch.session_id,
|
&e,
|
||||||
relaunch.minimal,
|
&relaunch.session_id,
|
||||||
&mut io::stderr(),
|
relaunch.minimal,
|
||||||
);
|
&mut io::stderr(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(false);
|
return Ok(RunExit {
|
||||||
|
quit_for_update: false,
|
||||||
|
terminal_reading,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if let Some(info) = run_result.exit_info {
|
if let Some(info) = run_result.exit_info
|
||||||
|
&& terminal_reading
|
||||||
|
{
|
||||||
print_exit_resume_hint(&info.session_id, info.minimal, &mut io::stderr());
|
print_exit_resume_hint(&info.session_id, info.minimal, &mut io::stderr());
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(RunExit {
|
||||||
|
quit_for_update: false,
|
||||||
|
terminal_reading,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
@@ -1065,15 +1092,23 @@ fn init_terminal(
|
|||||||
kigi_crash_handler::disable_terminal_escape_restore();
|
kigi_crash_handler::disable_terminal_escape_restore();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/// Drop the terminal (closing the writer mpsc channel) and join the
|
/// Drop the terminal and join the writer within `WRITER_JOIN_GRACE`; after `TimedOut` the writer may still hold the stderr lock, so callers must not take it unbounded.
|
||||||
/// writer thread. After this returns, subsequent direct stderr writes
|
|
||||||
/// are guaranteed to land strictly after every queued frame.
|
|
||||||
fn drain_writer_thread_before_teardown(
|
fn drain_writer_thread_before_teardown(
|
||||||
terminal: PagerTerminal,
|
terminal: PagerTerminal,
|
||||||
writer_thread: crate::render::draw::WriterThread,
|
writer_thread: crate::render::draw::WriterThread,
|
||||||
) {
|
) -> WriterJoin {
|
||||||
drop(terminal);
|
drop(terminal);
|
||||||
writer_thread.join();
|
let join = writer_thread.join_within(crate::render::draw::WRITER_JOIN_GRACE);
|
||||||
|
if join == WriterJoin::TimedOut {
|
||||||
|
crate::unified_log::warn(
|
||||||
|
"term.writer.join_timeout",
|
||||||
|
None,
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"grace_ms": crate::render::draw::WRITER_JOIN_GRACE.as_millis() as u64
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
join
|
||||||
}
|
}
|
||||||
/// Inline teardown escape sequences in the canonical order, shared by
|
/// Inline teardown escape sequences in the canonical order, shared by
|
||||||
/// `restore_terminal` and `set_panic_hook` so the on-wire byte order is
|
/// `restore_terminal` and `set_panic_hook` so the on-wire byte order is
|
||||||
@@ -1104,7 +1139,12 @@ fn emit_terminal_teardown_sequences(mode: ScreenMode, inline_cursor_row: Option<
|
|||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||||
let _ = execute!(stderr, event::DisableFocusChange);
|
let _ = execute!(stderr, event::DisableFocusChange);
|
||||||
});
|
});
|
||||||
pop_gboom_keyboard_flags();
|
// Teardown runs after the writer is gone, so this pop is inline.
|
||||||
|
if GBOOM_KEYBOARD_PUSHED.swap(false, Ordering::AcqRel) {
|
||||||
|
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||||
|
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
|
||||||
|
});
|
||||||
|
}
|
||||||
if crate::terminal::take_kitty_flags_pushed() {
|
if crate::terminal::take_kitty_flags_pushed() {
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||||
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
|
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
|
||||||
@@ -1134,15 +1174,57 @@ fn emit_terminal_teardown_sequences(mode: ScreenMode, inline_cursor_row: Option<
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
win_native_selection::restore_stdin_mode();
|
win_native_selection::restore_stdin_mode();
|
||||||
}
|
}
|
||||||
/// Consumes `terminal` and `writer_thread`: queues a final clear in
|
/// Bound on teardown writes when the stderr lock may be wedged (panic hook; a timed-out writer join).
|
||||||
/// fullscreen mode, drains the writer thread, then emits the inline
|
const TEARDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
/// teardown sequences. The drain ordering guarantees no late frame can
|
/// Run a best-effort teardown on a helper thread for at most `grace`; on timeout it is detached (the process is exiting), inline if no thread can spawn.
|
||||||
/// land after `LeaveAlternateScreen`.
|
fn run_bounded_teardown(f: impl FnOnce() + Send + 'static, grace: std::time::Duration) -> bool {
|
||||||
|
let slot = std::sync::Arc::new(parking_lot::Mutex::new(Some(f)));
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
let worker_slot = std::sync::Arc::clone(&slot);
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("bounded-teardown".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Some(f) = worker_slot.lock().take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
let _ = done_tx.send(());
|
||||||
|
});
|
||||||
|
match spawned {
|
||||||
|
Ok(_) => done_rx.recv_timeout(grace).is_ok(),
|
||||||
|
Err(_) => {
|
||||||
|
if let Some(f) = slot.lock().take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Clear (fullscreen), drain the writer, then emit the teardown sequences; `TimedOut` means the terminal is not reading and further stderr writes may block.
|
||||||
fn restore_terminal(
|
fn restore_terminal(
|
||||||
|
terminal: PagerTerminal,
|
||||||
|
writer_thread: crate::render::draw::WriterThread,
|
||||||
|
mode: ScreenMode,
|
||||||
|
) -> io::Result<WriterJoin> {
|
||||||
|
let join = restore_terminal_with(
|
||||||
|
terminal,
|
||||||
|
writer_thread,
|
||||||
|
mode,
|
||||||
|
drain_writer_thread_before_teardown,
|
||||||
|
emit_terminal_teardown_sequences,
|
||||||
|
)?;
|
||||||
|
if join == WriterJoin::TimedOut {
|
||||||
|
// Later exit output (hints, relaunch notice, update result, error report) must not block on a stalled tty.
|
||||||
|
kigi_tty_utils::silence_native_stderr();
|
||||||
|
}
|
||||||
|
Ok(join)
|
||||||
|
}
|
||||||
|
fn restore_terminal_with(
|
||||||
mut terminal: PagerTerminal,
|
mut terminal: PagerTerminal,
|
||||||
writer_thread: crate::render::draw::WriterThread,
|
writer_thread: crate::render::draw::WriterThread,
|
||||||
mode: ScreenMode,
|
mode: ScreenMode,
|
||||||
) -> io::Result<()> {
|
drain: impl FnOnce(PagerTerminal, crate::render::draw::WriterThread) -> WriterJoin,
|
||||||
|
teardown: impl FnOnce(ScreenMode, Option<u16>) + Send + 'static,
|
||||||
|
) -> io::Result<WriterJoin> {
|
||||||
if mode.is_fullscreen() {
|
if mode.is_fullscreen() {
|
||||||
let _ = terminal.clear();
|
let _ = terminal.clear();
|
||||||
{
|
{
|
||||||
@@ -1151,14 +1233,24 @@ fn restore_terminal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let inline_cursor_row = (!mode.is_fullscreen()).then(|| terminal.viewport_area().bottom());
|
let inline_cursor_row = (!mode.is_fullscreen()).then(|| terminal.viewport_area().bottom());
|
||||||
drain_writer_thread_before_teardown(terminal, writer_thread);
|
let join = drain(terminal, writer_thread);
|
||||||
emit_terminal_teardown_sequences(mode, inline_cursor_row);
|
// A clean join proves past writes landed, not that the terminal still reads: every teardown write is bounded.
|
||||||
|
let torn_down = run_bounded_teardown(move || teardown(mode, inline_cursor_row), TEARDOWN_GRACE);
|
||||||
|
if !torn_down {
|
||||||
|
tracing::warn!(
|
||||||
|
grace_ms = TEARDOWN_GRACE.as_millis() as u64,
|
||||||
|
"terminal teardown did not finish in time; detaching"
|
||||||
|
);
|
||||||
|
}
|
||||||
drain_pending_events_with_timeout(std::time::Duration::from_millis(10));
|
drain_pending_events_with_timeout(std::time::Duration::from_millis(10));
|
||||||
let _ = terminal::disable_raw_mode();
|
let _ = terminal::disable_raw_mode();
|
||||||
signal_handler::mark_restored();
|
signal_handler::mark_restored();
|
||||||
kigi_crash_handler::disable_terminal_escape_restore();
|
kigi_crash_handler::disable_terminal_escape_restore();
|
||||||
kigi_tty_utils::restore_native_stderr();
|
kigi_tty_utils::restore_native_stderr();
|
||||||
Ok(())
|
if join == WriterJoin::TimedOut || !torn_down {
|
||||||
|
return Ok(WriterJoin::TimedOut);
|
||||||
|
}
|
||||||
|
Ok(WriterJoin::Joined)
|
||||||
}
|
}
|
||||||
pub(crate) fn set_terminal_title(title: &str) {
|
pub(crate) fn set_terminal_title(title: &str) {
|
||||||
let full = terminal_title_string(title);
|
let full = terminal_title_string(title);
|
||||||
@@ -1183,18 +1275,88 @@ fn terminal_title_string(title: &str) -> String {
|
|||||||
fn set_panic_hook(mode: ScreenMode) {
|
fn set_panic_hook(mode: ScreenMode) {
|
||||||
let hook = panic::take_hook();
|
let hook = panic::take_hook();
|
||||||
panic::set_hook(Box::new(move |info| {
|
panic::set_hook(Box::new(move |info| {
|
||||||
emit_terminal_teardown_sequences(mode, None);
|
// The writer thread may hold the stderr lock in a blocked tty write.
|
||||||
|
let torn_down = run_bounded_teardown(
|
||||||
|
move || emit_terminal_teardown_sequences(mode, None),
|
||||||
|
TEARDOWN_GRACE,
|
||||||
|
);
|
||||||
let _ = terminal::disable_raw_mode();
|
let _ = terminal::disable_raw_mode();
|
||||||
signal_handler::mark_restored();
|
signal_handler::mark_restored();
|
||||||
kigi_crash_handler::disable_terminal_escape_restore();
|
kigi_crash_handler::disable_terminal_escape_restore();
|
||||||
kigi_tty_utils::restore_native_stderr();
|
kigi_tty_utils::restore_native_stderr();
|
||||||
kigi_tty_utils::global_process_scope().kill_all();
|
kigi_tty_utils::global_process_scope().kill_all();
|
||||||
|
if !torn_down {
|
||||||
|
// The previous hook prints to stderr, which is not reading; keep the log report only.
|
||||||
|
kigi_tty_utils::silence_native_stderr();
|
||||||
|
tracing::error!(panic = %info, "process panicked while the terminal was stalled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
hook(info);
|
hook(info);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
fn test_terminal_and_writer_thread() -> (PagerTerminal, crate::render::draw::WriterThread) {
|
||||||
|
let (frame_tx, writer_sync, writer_thread) = crate::render::draw::spawn_writer_thread();
|
||||||
|
let backend =
|
||||||
|
CrosstermBackend::new(crate::render::draw::TermWriter::new(frame_tx, writer_sync));
|
||||||
|
let terminal = kigi_ratatui_inline::Terminal::with_options(
|
||||||
|
backend,
|
||||||
|
ratatui::TerminalOptions {
|
||||||
|
viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, 80, 10)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("fixed-viewport terminal");
|
||||||
|
(terminal, writer_thread)
|
||||||
|
}
|
||||||
|
/// After a timed-out writer join the teardown must be bounded: the writer may hold the stderr lock.
|
||||||
|
#[test]
|
||||||
|
fn restore_bounds_teardown_after_a_timed_out_writer_join() {
|
||||||
|
let (terminal, writer_thread) = test_terminal_and_writer_thread();
|
||||||
|
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result = restore_terminal_with(
|
||||||
|
terminal,
|
||||||
|
writer_thread,
|
||||||
|
ScreenMode::Inline,
|
||||||
|
|terminal, writer_thread| {
|
||||||
|
drop(terminal);
|
||||||
|
drop(writer_thread);
|
||||||
|
WriterJoin::TimedOut
|
||||||
|
},
|
||||||
|
move |_, _| {
|
||||||
|
let _ = release_rx.recv();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(result.expect("restore"), WriterJoin::TimedOut);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < TEARDOWN_GRACE + std::time::Duration::from_secs(5),
|
||||||
|
"teardown must be detached at the grace deadline"
|
||||||
|
);
|
||||||
|
let _ = release_tx.send(());
|
||||||
|
}
|
||||||
|
/// A clean join followed by a teardown that never returns still reports a stalled terminal.
|
||||||
|
#[test]
|
||||||
|
fn restore_reports_a_stalled_terminal_when_teardown_times_out_after_a_clean_join() {
|
||||||
|
let (terminal, writer_thread) = test_terminal_and_writer_thread();
|
||||||
|
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
let result = restore_terminal_with(
|
||||||
|
terminal,
|
||||||
|
writer_thread,
|
||||||
|
ScreenMode::Inline,
|
||||||
|
|terminal, writer_thread| {
|
||||||
|
drop(terminal);
|
||||||
|
drop(writer_thread);
|
||||||
|
WriterJoin::Joined
|
||||||
|
},
|
||||||
|
move |_, _| {
|
||||||
|
let _ = release_rx.recv();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(result.expect("restore"), WriterJoin::TimedOut);
|
||||||
|
let _ = release_tx.send(());
|
||||||
|
}
|
||||||
/// `[ui].cursor_blink` tri-state → startup cursor policy; the `None`
|
/// `[ui].cursor_blink` tri-state → startup cursor policy; the `None`
|
||||||
/// default must be Inherit (emit nothing).
|
/// default must be Inherit (emit nothing).
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//! Stderr lines for paths where fd 2 may be dead: a closed pane fails every write, `eprintln!` panics on it, and under `panic = "abort"` that is a SIGABRT plus a crash report.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
/// Write `line` and a newline to `w`; `false` when the write failed.
|
||||||
|
pub fn write_line(w: &mut impl Write, line: &str) -> bool {
|
||||||
|
writeln!(w, "{line}").is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`write_line`] to process stderr, outcome discarded.
|
||||||
|
pub fn eprint_line(line: &str) {
|
||||||
|
write_line(&mut std::io::stderr(), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_line_appends_a_newline_and_reports_success() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
assert!(write_line(&mut buf, "Finishing session…"));
|
||||||
|
assert_eq!(buf, "Finishing session…\n".as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A closed read end fails every write with EPIPE, like the dead tty a closed pane leaves.
|
||||||
|
#[test]
|
||||||
|
fn write_line_reports_failure_on_a_dead_pipe() {
|
||||||
|
let (reader, mut writer) = std::io::pipe().expect("pipe");
|
||||||
|
drop(reader);
|
||||||
|
assert!(!write_line(&mut writer, "unreachable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! `spawn_kigi_shell`, sends the ACP lifecycle (init → auth → session → prompt),
|
//! `spawn_kigi_shell`, sends the ACP lifecycle (init → auth → session → prompt),
|
||||||
//! streams text to stdout, and exits cleanly via `CancellationToken`.
|
//! streams text to stdout, and exits cleanly via `CancellationToken`.
|
||||||
|
|
||||||
|
use crate::best_effort_stderr::eprint_line;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -459,7 +460,7 @@ impl HeadlessEmitter {
|
|||||||
|
|
||||||
fn on_error(&self, message: &str) {
|
fn on_error(&self, message: &str) {
|
||||||
match self.format {
|
match self.format {
|
||||||
OutputFormat::Plain => eprintln!("{message}"),
|
OutputFormat::Plain => eprint_line(message),
|
||||||
OutputFormat::StreamingJson | OutputFormat::Json => {
|
OutputFormat::StreamingJson | OutputFormat::Json => {
|
||||||
let mut err = serde_json::json!({"type":"error","message": message});
|
let mut err = serde_json::json!({"type":"error","message": message});
|
||||||
if let Some(usage) = &self.usage {
|
if let Some(usage) = &self.usage {
|
||||||
@@ -1276,7 +1277,7 @@ pub async fn run_single_turn(
|
|||||||
== Some("max_turns_reached");
|
== Some("max_turns_reached");
|
||||||
if is_max_turns {
|
if is_max_turns {
|
||||||
match emitter.format {
|
match emitter.format {
|
||||||
OutputFormat::Plain => eprintln!("Max turns reached"),
|
OutputFormat::Plain => eprint_line("Max turns reached"),
|
||||||
OutputFormat::StreamingJson => {
|
OutputFormat::StreamingJson => {
|
||||||
println!("{}", serde_json::json!({"type": "max_turns_reached"}))
|
println!("{}", serde_json::json!({"type": "max_turns_reached"}))
|
||||||
}
|
}
|
||||||
@@ -1644,7 +1645,9 @@ fn handle_ext_notification(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
OutputFormat::Plain => {
|
OutputFormat::Plain => {
|
||||||
eprintln!("Auto-compacting conversation ({percentage}% full)...");
|
eprint_line(&format!(
|
||||||
|
"Auto-compacting conversation ({percentage}% full)..."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
OutputFormat::Json => {}
|
OutputFormat::Json => {}
|
||||||
},
|
},
|
||||||
@@ -1652,7 +1655,7 @@ fn handle_ext_notification(
|
|||||||
OutputFormat::StreamingJson => {
|
OutputFormat::StreamingJson => {
|
||||||
println!("{}", serde_json::json!({"type": "auto_compact_completed"}));
|
println!("{}", serde_json::json!({"type": "auto_compact_completed"}));
|
||||||
}
|
}
|
||||||
OutputFormat::Plain => eprintln!("Conversation compacted."),
|
OutputFormat::Plain => eprint_line("Conversation compacted."),
|
||||||
OutputFormat::Json => {}
|
OutputFormat::Json => {}
|
||||||
},
|
},
|
||||||
XaiUpdate::AutoCompactFailed { error } => match format {
|
XaiUpdate::AutoCompactFailed { error } => match format {
|
||||||
@@ -1664,9 +1667,9 @@ fn handle_ext_notification(
|
|||||||
}
|
}
|
||||||
OutputFormat::Plain => {
|
OutputFormat::Plain => {
|
||||||
if error.trim().is_empty() {
|
if error.trim().is_empty() {
|
||||||
eprintln!("Auto-compact failed.");
|
eprint_line("Auto-compact failed.");
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Auto-compact failed: {error}");
|
eprint_line(&format!("Auto-compact failed: {error}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutputFormat::Json => {}
|
OutputFormat::Json => {}
|
||||||
@@ -1675,7 +1678,7 @@ fn handle_ext_notification(
|
|||||||
OutputFormat::StreamingJson => {
|
OutputFormat::StreamingJson => {
|
||||||
println!("{}", serde_json::json!({"type": "auto_compact_cancelled"}));
|
println!("{}", serde_json::json!({"type": "auto_compact_cancelled"}));
|
||||||
}
|
}
|
||||||
OutputFormat::Plain => eprintln!("Auto-compact cancelled."),
|
OutputFormat::Plain => eprint_line("Auto-compact cancelled."),
|
||||||
OutputFormat::Json => {}
|
OutputFormat::Json => {}
|
||||||
},
|
},
|
||||||
XaiUpdate::AutoContinueCompleted { total_tokens } => match format {
|
XaiUpdate::AutoContinueCompleted { total_tokens } => match format {
|
||||||
@@ -1685,7 +1688,7 @@ fn handle_ext_notification(
|
|||||||
serde_json::json!({"type": "auto_continue_completed", "total_tokens": total_tokens})
|
serde_json::json!({"type": "auto_continue_completed", "total_tokens": total_tokens})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
OutputFormat::Plain => eprintln!("Resumed after compaction."),
|
OutputFormat::Plain => eprint_line("Resumed after compaction."),
|
||||||
OutputFormat::Json => {}
|
OutputFormat::Json => {}
|
||||||
},
|
},
|
||||||
XaiUpdate::ImageCompressed { message } => match format {
|
XaiUpdate::ImageCompressed { message } => match format {
|
||||||
@@ -1695,7 +1698,7 @@ fn handle_ext_notification(
|
|||||||
serde_json::json!({"type": "image_compressed", "message": message})
|
serde_json::json!({"type": "image_compressed", "message": message})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
OutputFormat::Plain => eprintln!("{message}"),
|
OutputFormat::Plain => eprint_line(&message),
|
||||||
OutputFormat::Json => {}
|
OutputFormat::Json => {}
|
||||||
},
|
},
|
||||||
XaiUpdate::SubagentSpawned { subagent_id } => {
|
XaiUpdate::SubagentSpawned { subagent_id } => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
pub mod acp;
|
pub mod acp;
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod best_effort_stderr;
|
||||||
pub mod client_identity;
|
pub mod client_identity;
|
||||||
pub mod completions_cmd;
|
pub mod completions_cmd;
|
||||||
mod config_toml_edit;
|
mod config_toml_edit;
|
||||||
|
|||||||
@@ -42,10 +42,15 @@ pub struct NotificationService {
|
|||||||
/// `true` after the first notification; cleared via
|
/// `true` after the first notification; cleared via
|
||||||
/// [`clear_permission_notification`] when the queue drains to empty.
|
/// [`clear_permission_notification`] when the queue drains to empty.
|
||||||
permission_notified: bool,
|
permission_notified: bool,
|
||||||
|
/// Notification, title and progress escapes ride the frame queue (see `EscapeWriter`).
|
||||||
|
escape_writer: crate::render::draw::EscapeWriter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NotificationService {
|
impl NotificationService {
|
||||||
pub fn new(config: NotificationConfig) -> Self {
|
pub fn new(
|
||||||
|
config: NotificationConfig,
|
||||||
|
escape_writer: crate::render::draw::EscapeWriter,
|
||||||
|
) -> Self {
|
||||||
let terminal_ctx = crate::terminal::terminal_context();
|
let terminal_ctx = crate::terminal::terminal_context();
|
||||||
let protocol = resolve_protocol(config.method, terminal_ctx);
|
let protocol = resolve_protocol(config.method, terminal_ctx);
|
||||||
let focus_tracker = focus::FocusTracker::new(
|
let focus_tracker = focus::FocusTracker::new(
|
||||||
@@ -64,6 +69,7 @@ impl NotificationService {
|
|||||||
progress_active: false,
|
progress_active: false,
|
||||||
progress_last_sent: None,
|
progress_last_sent: None,
|
||||||
permission_notified: false,
|
permission_notified: false,
|
||||||
|
escape_writer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +117,7 @@ impl NotificationService {
|
|||||||
&event.title,
|
&event.title,
|
||||||
&event.body,
|
&event.body,
|
||||||
self.terminal_ctx,
|
self.terminal_ctx,
|
||||||
|
&self.escape_writer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,13 +139,7 @@ impl NotificationService {
|
|||||||
self.clear_progress_into(&mut buf);
|
self.clear_progress_into(&mut buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !buf.is_empty() {
|
self.escape_writer.emit(buf);
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
|
||||||
use std::io::Write;
|
|
||||||
let _ = stderr.write_all(buf.as_bytes());
|
|
||||||
let _ = stderr.flush();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build escape sequences to set the title and progress bar to idle
|
/// Build escape sequences to set the title and progress bar to idle
|
||||||
@@ -206,22 +207,9 @@ impl NotificationService {
|
|||||||
pub fn shutdown(&mut self) {
|
pub fn shutdown(&mut self) {
|
||||||
// Reset the tab title back to "kigi" so it doesn't linger on the
|
// Reset the tab title back to "kigi" so it doesn't linger on the
|
||||||
// last activity label after exit.
|
// last activity label after exit.
|
||||||
let title_esc = self.title_manager.reset();
|
let mut buf = self.title_manager.reset();
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
|
||||||
use std::io::Write as _;
|
|
||||||
let _ = stderr.write_all(title_esc.as_bytes());
|
|
||||||
let _ = stderr.flush();
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut buf = String::new();
|
|
||||||
self.clear_progress_into(&mut buf);
|
self.clear_progress_into(&mut buf);
|
||||||
if !buf.is_empty() {
|
self.escape_writer.emit(buf);
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
|
||||||
use std::io::Write as _;
|
|
||||||
let _ = stderr.write_all(buf.as_bytes());
|
|
||||||
let _ = stderr.flush();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if a terminal notification for `ApprovalRequired` has
|
/// Returns `true` if a terminal notification for `ApprovalRequired` has
|
||||||
@@ -281,6 +269,7 @@ impl NotificationService {
|
|||||||
progress_active: false,
|
progress_active: false,
|
||||||
progress_last_sent: None,
|
progress_last_sent: None,
|
||||||
permission_notified: false,
|
permission_notified: false,
|
||||||
|
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,6 +650,19 @@ mod tests {
|
|||||||
assert!(!svc.is_progress_active());
|
assert!(!svc.is_progress_active());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shutdown escapes ride the writer queue, never an inline stderr write.
|
||||||
|
#[test]
|
||||||
|
fn shutdown_queues_the_title_reset_on_the_writer() {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||||
|
let mut svc = NotificationService::new(
|
||||||
|
NotificationConfig::default(),
|
||||||
|
crate::render::draw::EscapeWriter::new(tx, crate::render::draw::WriterSync::new()),
|
||||||
|
);
|
||||||
|
svc.shutdown();
|
||||||
|
let payload = rx.try_recv().expect("shutdown must queue its escapes");
|
||||||
|
assert!(payload.starts_with(b"\x1b]"), "{payload:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shutdown_clears_active_progress() {
|
fn shutdown_clears_active_progress() {
|
||||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
use crate::notifications::tmux;
|
use crate::notifications::tmux;
|
||||||
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
|
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
|
||||||
@@ -68,6 +67,7 @@ pub fn emit_notification(
|
|||||||
title: &str,
|
title: &str,
|
||||||
body: &str,
|
body: &str,
|
||||||
ctx: &TerminalContext,
|
ctx: &TerminalContext,
|
||||||
|
writer: &crate::render::draw::EscapeWriter,
|
||||||
) {
|
) {
|
||||||
// For body-only protocols (OSC 9, OSC 99), fold the title (session
|
// For body-only protocols (OSC 9, OSC 99), fold the title (session
|
||||||
// name) into the body so it's visible. For OSC 777 (Ghostty), the
|
// name) into the body so it's visible. For OSC 777 (Ghostty), the
|
||||||
@@ -81,23 +81,13 @@ pub fn emit_notification(
|
|||||||
NotificationProtocol::None => return,
|
NotificationProtocol::None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notifications fire from the event-loop thread: queued, never an inline stderr write.
|
||||||
if ctx.is_tmux_backed() {
|
if ctx.is_tmux_backed() {
|
||||||
let wrapped = tmux::tmux_passthrough(&sequence);
|
writer.emit(tmux::tmux_passthrough(&sequence));
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
|
||||||
let _ = stderr.write_all(wrapped.as_bytes());
|
|
||||||
let _ = stderr.flush();
|
|
||||||
});
|
|
||||||
} else if matches!(protocol, NotificationProtocol::Bel) {
|
} else if matches!(protocol, NotificationProtocol::Bel) {
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
writer.emit(BEL_BYTE);
|
||||||
let _ = stderr.write_all(BEL_BYTE);
|
|
||||||
let _ = stderr.flush();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
let bytes = sequence.as_bytes();
|
writer.emit(sequence.as_bytes());
|
||||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
|
||||||
let _ = stderr.write_all(bytes);
|
|
||||||
let _ = stderr.flush();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,31 +282,61 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn emit_none_is_noop() {
|
fn emit_none_is_noop() {
|
||||||
let ctx = ctx_with_brand(TerminalName::KigiDesktop);
|
let ctx = ctx_with_brand(TerminalName::KigiDesktop);
|
||||||
emit_notification(NotificationProtocol::None, "title", "body", &ctx);
|
emit_notification(
|
||||||
|
NotificationProtocol::None,
|
||||||
|
"title",
|
||||||
|
"body",
|
||||||
|
&ctx,
|
||||||
|
&crate::render::draw::EscapeWriter::disconnected(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_bel_does_not_panic() {
|
fn emit_bel_does_not_panic() {
|
||||||
let ctx = ctx_with_brand(TerminalName::Unknown);
|
let ctx = ctx_with_brand(TerminalName::Unknown);
|
||||||
emit_notification(NotificationProtocol::Bel, "", "", &ctx);
|
emit_notification(
|
||||||
|
NotificationProtocol::Bel,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
&ctx,
|
||||||
|
&crate::render::draw::EscapeWriter::disconnected(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_osc9_does_not_panic() {
|
fn emit_osc9_does_not_panic() {
|
||||||
let ctx = ctx_with_brand(TerminalName::Iterm2);
|
let ctx = ctx_with_brand(TerminalName::Iterm2);
|
||||||
emit_notification(NotificationProtocol::Osc9, "title", "body", &ctx);
|
emit_notification(
|
||||||
|
NotificationProtocol::Osc9,
|
||||||
|
"title",
|
||||||
|
"body",
|
||||||
|
&ctx,
|
||||||
|
&crate::render::draw::EscapeWriter::disconnected(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_osc99_does_not_panic() {
|
fn emit_osc99_does_not_panic() {
|
||||||
let ctx = ctx_with_brand(TerminalName::Kitty);
|
let ctx = ctx_with_brand(TerminalName::Kitty);
|
||||||
emit_notification(NotificationProtocol::Osc99, "title", "body", &ctx);
|
emit_notification(
|
||||||
|
NotificationProtocol::Osc99,
|
||||||
|
"title",
|
||||||
|
"body",
|
||||||
|
&ctx,
|
||||||
|
&crate::render::draw::EscapeWriter::disconnected(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_osc777_does_not_panic() {
|
fn emit_osc777_does_not_panic() {
|
||||||
let ctx = ctx_with_brand(TerminalName::Ghostty);
|
let ctx = ctx_with_brand(TerminalName::Ghostty);
|
||||||
emit_notification(NotificationProtocol::Osc777, "title", "body", &ctx);
|
emit_notification(
|
||||||
|
NotificationProtocol::Osc777,
|
||||||
|
"title",
|
||||||
|
"body",
|
||||||
|
&ctx,
|
||||||
|
&crate::render::draw::EscapeWriter::disconnected(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, Slas
|
|||||||
|
|
||||||
/// Online docs landing page (hardcoded like other TUI deep-links; the
|
/// Online docs landing page (hardcoded like other TUI deep-links; the
|
||||||
/// project README is the docs entry point for the repo-hosted docs).
|
/// project README is the docs entry point for the repo-hosted docs).
|
||||||
pub const BUILD_DOCS_URL: &str =
|
pub const BUILD_DOCS_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/blob/main/README.md";
|
||||||
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/src/branch/main/README.md";
|
|
||||||
|
|
||||||
/// Open How-to Guides or online Build docs.
|
/// Open How-to Guides or online Build docs.
|
||||||
pub struct DocsCommand;
|
pub struct DocsCommand;
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
|||||||
/// Where feedback goes: the project's own issue tracker. Kigi is a community
|
/// Where feedback goes: the project's own issue tracker. Kigi is a community
|
||||||
/// build, so its feedback belongs on its own repo — mirroring the official
|
/// build, so its feedback belongs on its own repo — mirroring the official
|
||||||
/// kimi-cli, whose `/feedback` opens its repo's issues page.
|
/// kimi-cli, whose `/feedback` opens its repo's issues page.
|
||||||
pub const FEEDBACK_ISSUES_URL: &str =
|
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
|
||||||
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues";
|
|
||||||
|
|
||||||
/// Open the Kigi issue tracker in the browser.
|
/// Open the Kigi issue tracker in the browser.
|
||||||
pub struct FeedbackCommand;
|
pub struct FeedbackCommand;
|
||||||
|
|||||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user