Merge remote-tracking branch 'can1357/main' into fix/provider-onpayload-replacements
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
name: Build native addon
|
||||
description: Build the pi_natives cdylib for one platform/arch/variant and upload it as a hash-tagged artifact.
|
||||
description: >
|
||||
Build the pi_natives cdylib for one platform/arch/variant and upload it as a
|
||||
hash-tagged artifact. Self-detects the runner via $SCCACHE_BUCKET (injected
|
||||
only on the self-hosted omp-kata pods): on-infra it uses the image's baked
|
||||
toolchains + the RustFS-backed sccache; on GitHub-hosted runners it installs
|
||||
the toolchains and uses Swatinem target/ cache + the GitHub Actions sccache
|
||||
backend. PRs run on GitHub-hosted runners, so the on-infra path only ever
|
||||
serves trusted push/main + release builds.
|
||||
|
||||
inputs:
|
||||
hash:
|
||||
@@ -19,46 +26,114 @@ inputs:
|
||||
description: Optional rustc target triple for cross-compilation
|
||||
required: false
|
||||
default: ""
|
||||
glibc:
|
||||
description: >
|
||||
Optional glibc floor (e.g. "2.17") for linux-gnu builds. Routes the build
|
||||
through cargo-zigbuild against that floor without affecting the rustup
|
||||
target or the host-arch native test steps. Combine with `target` to pin a
|
||||
cross-arch linux build, or set alone for a host-arch (x64) linux build.
|
||||
required: false
|
||||
default: ""
|
||||
rust_checks:
|
||||
description: Run clippy/rustfmt checks (only one matrix entry should set this)
|
||||
required: false
|
||||
default: "false"
|
||||
save_cache:
|
||||
description: Whether Swatinem/rust-cache should write a cache entry
|
||||
description: Whether Swatinem/rust-cache should write a cache entry (GitHub-hosted only)
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
- name: Detect runner environment
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
# $SCCACHE_BUCKET is injected only on the self-hosted omp-kata runner
|
||||
# pods (envFrom sccache-s3); its presence is the repo's single
|
||||
# "on can.internal infra?" signal. On-infra: baked toolchains, RustFS
|
||||
# sccache, no GitHub target/ cache. Off-infra (GitHub-hosted): install
|
||||
# toolchains, Swatinem target/ cache, GitHub Actions sccache backend.
|
||||
if [ -n "${SCCACHE_BUCKET:-}" ]; then
|
||||
echo "on_infra=true" >> "$GITHUB_OUTPUT"
|
||||
echo "runner: self-hosted omp-kata (baked tools + RustFS sccache)"
|
||||
else
|
||||
echo "on_infra=false" >> "$GITHUB_OUTPUT"
|
||||
echo "runner: GitHub-hosted (install tools + Swatinem cache + GHA sccache)"
|
||||
fi
|
||||
|
||||
- name: Resolve build targets
|
||||
id: resolve
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ inputs.target }}
|
||||
GLIBC: ${{ inputs.glibc }}
|
||||
ARCH: ${{ inputs.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `cross_target` is what the napi build feeds cargo: cargo-zigbuild reads
|
||||
# the glibc floor as a `.<major>.<minor>` triple suffix. `bare_target` is
|
||||
# what rustup needs — never glibc-suffixed (rustup rejects the suffix)
|
||||
# and empty for host-arch builds whose target is already installed.
|
||||
base="$TARGET"
|
||||
if [ -z "$base" ]; then
|
||||
case "$ARCH" in
|
||||
x64) base="x86_64-unknown-linux-gnu" ;;
|
||||
arm64) base="aarch64-unknown-linux-gnu" ;;
|
||||
esac
|
||||
fi
|
||||
cross_target=""
|
||||
if [ -n "$GLIBC" ]; then
|
||||
if [ -z "$base" ]; then
|
||||
echo "::error::glibc floor '$GLIBC' set but no linux-gnu base triple for arch '$ARCH'"
|
||||
exit 1
|
||||
fi
|
||||
cross_target="${base}.${GLIBC}"
|
||||
elif [ -n "$TARGET" ]; then
|
||||
cross_target="$TARGET"
|
||||
fi
|
||||
bare_target="${TARGET%%.*}"
|
||||
{
|
||||
echo "cross_target=$cross_target"
|
||||
echo "bare_target=$bare_target"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved cross_target='$cross_target' bare_target='$bare_target'"
|
||||
|
||||
# --- Rust toolchain -----------------------------------------------------
|
||||
- name: Ensure baked Rust toolchain (omp-kata)
|
||||
if: steps.detect.outputs.on_infra == 'true'
|
||||
uses: ./.github/actions/ensure-rust-toolchain
|
||||
with:
|
||||
toolchain: nightly-2026-04-29
|
||||
components: ${{ inputs.rust_checks == 'true' && 'clippy,rustfmt' || '' }}
|
||||
target: ${{ steps.resolve.outputs.bare_target }}
|
||||
- name: Install Rust toolchain (GitHub-hosted)
|
||||
if: steps.detect.outputs.on_infra == 'false'
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
toolchain: nightly-2026-04-29
|
||||
components: ${{ inputs.rust_checks == 'true' && 'clippy, rustfmt' || '' }}
|
||||
targets: ${{ inputs.target }}
|
||||
- name: Prepend rustup toolchain bin to PATH
|
||||
targets: ${{ steps.resolve.outputs.bare_target }}
|
||||
- name: Install Linux build prerequisites (GitHub-hosted)
|
||||
if: steps.detect.outputs.on_infra == 'false' && runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
# Homebrew on macOS runners ships rustup-init with shadow proxies
|
||||
# for `cargo`/`rustc`/etc. that error out as the installer
|
||||
# ("unexpected argument 'metadata' found"). Force the real
|
||||
# toolchain binaries to win on PATH.
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential
|
||||
- name: Prepend rustup toolchain bin to PATH (GitHub-hosted)
|
||||
if: steps.detect.outputs.on_infra == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
# Homebrew on macOS runners ships rustup-init with shadow proxies for
|
||||
# `cargo`/`rustc`/etc. that error out as the installer ("unexpected
|
||||
# argument 'metadata' found"). Force the real toolchain binaries to win
|
||||
# on PATH. (ensure-rust-toolchain already does this on omp-kata.)
|
||||
toolchain_bin="$(dirname "$(rustup which cargo)")"
|
||||
echo "$toolchain_bin" >> "$GITHUB_PATH"
|
||||
echo "Prepended $toolchain_bin to PATH"
|
||||
# `Swatinem/rust-cache` keys target/ off its restore-time environment, so
|
||||
# set RUSTFLAGS before restoring it. If x64 target-cpu is only selected
|
||||
# inside ci-build-native.ts/build-native.ts, cargo invalidates the restored
|
||||
# target/ but rust-cache sees an exact key and refuses to save the rebuilt
|
||||
# artifacts, causing macOS x64 baseline to rebuild forever.
|
||||
#
|
||||
# Include the native source hash in the shared key as well: rust-cache's
|
||||
# lockfile scan misses the workspace root Cargo.toml version that Cargo
|
||||
# fingerprints for workspace crates. Without it, release version bumps can
|
||||
# get an exact hit for artifacts Cargo must rebuild.
|
||||
#
|
||||
# sccache is still layered on top of rust-cache: target/ wins when warm,
|
||||
# sccache fills the gaps when target/ is cold.
|
||||
|
||||
# --- Rust flags (shared) ------------------------------------------------
|
||||
- name: Configure native Rust flags
|
||||
if: inputs.target == ''
|
||||
shell: bash
|
||||
@@ -88,87 +163,141 @@ runs:
|
||||
|
||||
echo "RUSTFLAGS=$rustflags" >> "$GITHUB_ENV"
|
||||
echo "Configured RUSTFLAGS=$rustflags"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# --- target/ cache (GitHub-hosted only) ---------------------------------
|
||||
# Swatinem keys target/ off its restore-time environment, so it must run
|
||||
# after RUSTFLAGS is set: if x64 target-cpu were only selected later, cargo
|
||||
# would invalidate the restored target/ while rust-cache saw an exact key
|
||||
# and refused to save the rebuilt artifacts (macOS x64 baseline rebuilds
|
||||
# forever). The native source hash is in the shared key too: rust-cache's
|
||||
# lockfile scan misses the workspace-root Cargo.toml version Cargo
|
||||
# fingerprints, so a version bump could otherwise get an exact hit for
|
||||
# artifacts Cargo must rebuild. On omp-kata the mounted Cargo registry +
|
||||
# RustFS sccache are the reuse layers, so there is no second cache restore.
|
||||
- name: Cache Rust target/ (GitHub-hosted)
|
||||
if: steps.detect.outputs.on_infra == 'false'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: native-${{ inputs.platform }}-${{ inputs.arch }}-${{ inputs.variant || 'default' }}-h${{ inputs.hash }}
|
||||
cache-on-failure: true
|
||||
save-if: ${{ inputs.save_cache == 'true' }}
|
||||
cache-workspace-crates: true
|
||||
- name: Setup sccache
|
||||
|
||||
# --- sccache ------------------------------------------------------------
|
||||
- name: Ensure baked sccache (omp-kata)
|
||||
if: steps.detect.outputs.on_infra == 'true'
|
||||
uses: ./.github/actions/ensure-sccache
|
||||
with:
|
||||
version: "0.15.0"
|
||||
- name: Setup sccache (GitHub-hosted)
|
||||
if: steps.detect.outputs.on_infra == 'false'
|
||||
uses: mozilla-actions/sccache-action@v0.0.10
|
||||
- name: Enable sccache for cargo
|
||||
# `CARGO_INCREMENTAL=0` is required: sccache silently skips caching
|
||||
# when incremental is enabled, which would turn the wrapper into a
|
||||
# no-op. rust-cache also sets this today, but pin it here so sccache
|
||||
# stays effective if rust-cache is removed or changes defaults. Also
|
||||
# overrides `profile.dev.incremental = true` for `bun run test:rs`.
|
||||
# CARGO_INCREMENTAL=0 is required: sccache silently skips caching when
|
||||
# incremental is enabled, turning the wrapper into a no-op. The backend is
|
||||
# conditional: omp-kata reads the shared S3 (RustFS) config from the
|
||||
# inherited pod env; GitHub-hosted runners use the GHA cache backend.
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_GHA_ENABLED=true"
|
||||
echo "RUSTC_WRAPPER=sccache"
|
||||
echo "CARGO_INCREMENTAL=0"
|
||||
} >> "$GITHUB_ENV"
|
||||
- uses: taiki-e/install-action@v2
|
||||
if: inputs.target == ''
|
||||
if [ -n "${SCCACHE_BUCKET:-}" ]; then
|
||||
echo "sccache backend: shared S3 ($SCCACHE_BUCKET @ $SCCACHE_ENDPOINT)"
|
||||
else
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
|
||||
echo "sccache backend: GitHub Actions cache"
|
||||
fi
|
||||
|
||||
# --- cargo-nextest (native test runner; non-cross builds only) ----------
|
||||
- name: Ensure baked cargo-nextest (omp-kata)
|
||||
if: steps.detect.outputs.on_infra == 'true' && inputs.target == ''
|
||||
uses: ./.github/actions/ensure-cargo-tool
|
||||
with:
|
||||
binary: cargo-nextest
|
||||
crate: cargo-nextest
|
||||
- name: Install cargo-nextest (GitHub-hosted)
|
||||
if: steps.detect.outputs.on_infra == 'false' && inputs.target == ''
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: nextest
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- uses: ./.github/actions/bun-install
|
||||
|
||||
# --- Cross-compile toolchains -------------------------------------------
|
||||
# Non-MSVC targets (e.g. aarch64-unknown-linux-gnu) build with
|
||||
# cargo-zigbuild (needs zig); MSVC targets (e.g. x86_64-pc-windows-msvc)
|
||||
# build with cargo-xwin (needs clang/lld/llvm). The napi CLI's
|
||||
# --cross-compile flag picks the backend; we just install what it needs.
|
||||
# Cross builds only run on push/main + release (omp-kata), so the
|
||||
# GitHub-hosted cross branches exist for portability and never fire here.
|
||||
- name: Ensure baked zig (omp-kata, non-MSVC cross)
|
||||
if: steps.detect.outputs.on_infra == 'true' && steps.resolve.outputs.cross_target != '' && !endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
uses: ./.github/actions/ensure-zig
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- shell: bash
|
||||
run: bun install --frozen-lockfile
|
||||
# Cross-compile toolchain selection: non-MSVC targets (e.g.
|
||||
# `aarch64-unknown-linux-gnu`) build with `cargo-zigbuild`; MSVC targets
|
||||
# (e.g. `x86_64-pc-windows-msvc`) build with `cargo-xwin`. The napi CLI's
|
||||
# `--cross-compile` flag picks the backend; we just install what it needs.
|
||||
- name: Setup zig (non-MSVC cross-compile)
|
||||
if: inputs.target != '' && !endsWith(inputs.target, '-msvc')
|
||||
version: "0.16.0"
|
||||
- name: Setup zig (GitHub-hosted, non-MSVC cross)
|
||||
if: steps.detect.outputs.on_infra == 'false' && steps.resolve.outputs.cross_target != '' && !endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.16.0
|
||||
- name: Install cargo-zigbuild (non-MSVC cross-compile)
|
||||
if: inputs.target != '' && !endsWith(inputs.target, '-msvc')
|
||||
- name: Ensure baked cargo-zigbuild (omp-kata, non-MSVC cross)
|
||||
if: steps.detect.outputs.on_infra == 'true' && steps.resolve.outputs.cross_target != '' && !endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
uses: ./.github/actions/ensure-cargo-tool
|
||||
with:
|
||||
binary: cargo-zigbuild
|
||||
crate: cargo-zigbuild
|
||||
- name: Install cargo-zigbuild (GitHub-hosted, non-MSVC cross)
|
||||
if: steps.detect.outputs.on_infra == 'false' && steps.resolve.outputs.cross_target != '' && !endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
- name: Install LLVM tooling (MSVC cross-compile)
|
||||
if: endsWith(inputs.target, '-msvc')
|
||||
- name: Install LLVM tooling (GitHub-hosted, MSVC cross)
|
||||
if: steps.detect.outputs.on_infra == 'false' && endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang lld llvm
|
||||
- name: Install cargo-xwin (MSVC cross-compile)
|
||||
if: endsWith(inputs.target, '-msvc')
|
||||
- name: Ensure baked cargo-xwin (omp-kata, MSVC cross)
|
||||
if: steps.detect.outputs.on_infra == 'true' && endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
uses: ./.github/actions/ensure-cargo-tool
|
||||
with:
|
||||
binary: cargo-xwin
|
||||
crate: cargo-xwin
|
||||
- name: Install cargo-xwin (GitHub-hosted, MSVC cross)
|
||||
if: steps.detect.outputs.on_infra == 'false' && endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-xwin
|
||||
- name: Cache cargo-xwin Windows SDK
|
||||
if: endsWith(inputs.target, '-msvc')
|
||||
if: endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/cargo-xwin
|
||||
key: cargo-xwin-${{ runner.os }}-v1
|
||||
- name: Accept xwin license
|
||||
if: endsWith(inputs.target, '-msvc')
|
||||
if: endsWith(steps.resolve.outputs.cross_target, '-msvc')
|
||||
shell: bash
|
||||
run: echo "XWIN_ACCEPT_LICENSE=1" >> "$GITHUB_ENV"
|
||||
|
||||
# --- Checks, build, upload (shared) -------------------------------------
|
||||
- name: Rust checks
|
||||
if: inputs.rust_checks == 'true'
|
||||
shell: bash
|
||||
run: bun run check:rs
|
||||
- name: Test workspace (Rust)
|
||||
# macOS has no `#[cfg(target_os = "macos")]` tests in the workspace,
|
||||
# and Windows-only tests are no longer exercised in CI (`win32-x64`
|
||||
# cross-builds on Linux). Skipping the duplicate Linux runs on macOS
|
||||
# saves ~10 min of parallel runner time.
|
||||
# macOS has no `#[cfg(target_os = "macos")]` tests in the workspace, and
|
||||
# Windows-only tests are no longer exercised in CI (win32-x64 cross-builds
|
||||
# on Linux). Skipping the duplicate Linux runs on macOS saves ~10 min of
|
||||
# parallel runner time.
|
||||
if: inputs.target == '' && inputs.platform != 'darwin'
|
||||
shell: bash
|
||||
run: bun run test:rs
|
||||
- name: Build native addon(s)
|
||||
shell: bash
|
||||
env:
|
||||
CROSS_TARGET: ${{ inputs.target }}
|
||||
CROSS_TARGET: ${{ steps.resolve.outputs.cross_target }}
|
||||
TARGET_PLATFORM: ${{ inputs.platform }}
|
||||
TARGET_ARCH: ${{ inputs.arch }}
|
||||
TARGET_VARIANTS: ${{ inputs.variant }}
|
||||
@@ -179,7 +308,7 @@ runs:
|
||||
name: pi-natives-${{ inputs.platform }}-${{ inputs.arch }}${{ inputs.variant && format('-{0}', inputs.variant) || '' }}-h${{ inputs.hash }}
|
||||
path: packages/natives/native/pi_natives.${{ inputs.platform }}-${{ inputs.arch }}*.node
|
||||
if-no-files-found: error
|
||||
# Explicit so the native_artifact_lookup canary keeps working even if org
|
||||
# defaults shift; bump if Rust source ever stays stable for >90 days
|
||||
# Explicit so the native_artifact_lookup canary keeps working even if
|
||||
# org defaults shift; bump if Rust source ever stays stable for >90 days
|
||||
# of main pushes and you want to avoid rebuilds.
|
||||
retention-days: 90
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: "bun install (shared cache)"
|
||||
description: >
|
||||
Ensure bun is on PATH, then run `bun install --frozen-lockfile` with a shared
|
||||
package store. bun setup is skipped when the runner image already ships it
|
||||
(the preloaded omp-kata image), and only fetched on runners that lack it
|
||||
(e.g. GitHub-hosted). Self-hosted omp-kata runners use the mounted Bun store
|
||||
PVC; GitHub-hosted runners use the stock actions/cache backend.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect environment
|
||||
id: env
|
||||
shell: bash
|
||||
run: |
|
||||
# bun is baked into the omp-kata runner image — only fetch it when the
|
||||
# runner doesn't already provide it (GitHub-hosted), so we don't
|
||||
# re-download bun from GitHub releases on every job.
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
echo "setup_bun=false" >> "$GITHUB_OUTPUT"
|
||||
echo "bun present: $(bun --version) (preloaded image)"
|
||||
else
|
||||
echo "setup_bun=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# The repo keys "are we on can.internal infra?" off $SCCACHE_BUCKET (see
|
||||
# actions/build-native). RUNNER_ENVIRONMENT is empty on ARC pods, so it
|
||||
# is not a usable signal here. On infra, the ARC pod mounts the shared
|
||||
# Bun store at the default cache path; off infra, actions/cache restores it.
|
||||
if [ -n "${SCCACHE_BUCKET:-}" ]; then
|
||||
echo "cache=mounted" >> "$GITHUB_OUTPUT"
|
||||
echo "bun cache backend: mounted PVC (${BUN_INSTALL_CACHE_DIR:-${HOME}/.bun/install/cache})"
|
||||
else
|
||||
echo "cache=gha" >> "$GITHUB_OUTPUT"
|
||||
echo "bun cache backend: GitHub Actions cache"
|
||||
fi
|
||||
- name: Install bun when absent
|
||||
if: steps.env.outputs.setup_bun == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
export BUN_INSTALL="${HOME}/.bun"
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"
|
||||
echo "${BUN_INSTALL}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Off-infra (GitHub-hosted): stock actions/cache for the bun store.
|
||||
- name: Cache bun store (GitHub cache)
|
||||
if: steps.env.outputs.cache == 'gha'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
|
||||
# On-infra (omp-kata): the pod mounts a shared PVC at bun's store path.
|
||||
- name: Prepare mounted bun store
|
||||
if: steps.env.outputs.cache == 'mounted'
|
||||
shell: bash
|
||||
run: mkdir -p "${BUN_INSTALL_CACHE_DIR:-${HOME}/.bun/install/cache}"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -0,0 +1,38 @@
|
||||
name: "ensure cargo tool"
|
||||
description: Ensure a cargo-installed CLI is present.
|
||||
|
||||
inputs:
|
||||
binary:
|
||||
required: true
|
||||
description: Binary name expected on PATH
|
||||
crate:
|
||||
required: false
|
||||
default: ""
|
||||
description: Crate name to cargo install; defaults to the binary name
|
||||
version:
|
||||
required: false
|
||||
default: ""
|
||||
description: Optional crate version
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
env:
|
||||
BINARY: ${{ inputs.binary }}
|
||||
CRATE: ${{ inputs.crate }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v "$BINARY" >/dev/null 2>&1; then
|
||||
echo "Using baked cargo tool: $BINARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
crate="${CRATE:-$BINARY}"
|
||||
install_args=(install --locked "$crate")
|
||||
if [ -n "$VERSION" ]; then
|
||||
install_args+=(--version "$VERSION")
|
||||
fi
|
||||
cargo "${install_args[@]}"
|
||||
echo "Installed cargo tool: $BINARY"
|
||||
@@ -0,0 +1,59 @@
|
||||
name: "ensure rust toolchain"
|
||||
description: >
|
||||
Ensure a pinned rustup toolchain, optional components, and an optional target
|
||||
are present, then prepend the real toolchain bin dir to PATH.
|
||||
|
||||
inputs:
|
||||
toolchain:
|
||||
required: true
|
||||
description: Rust toolchain name (for example nightly-2026-04-29)
|
||||
components:
|
||||
required: false
|
||||
default: ""
|
||||
description: Optional comma-separated rustup components
|
||||
target:
|
||||
required: false
|
||||
default: ""
|
||||
description: Optional rustup target triple
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
env:
|
||||
TOOLCHAIN: ${{ inputs.toolchain }}
|
||||
COMPONENTS: ${{ inputs.components }}
|
||||
TARGET: ${{ inputs.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain "$TOOLCHAIN" --profile minimal
|
||||
fi
|
||||
|
||||
if ! rustc +"$TOOLCHAIN" --version >/dev/null 2>&1; then
|
||||
rustup toolchain install "$TOOLCHAIN" --profile minimal --no-self-update
|
||||
fi
|
||||
rustup default "$TOOLCHAIN"
|
||||
|
||||
missing_components=()
|
||||
if [ -n "$COMPONENTS" ]; then
|
||||
IFS=',' read -r -a wanted_components <<< "$COMPONENTS"
|
||||
for component in "${wanted_components[@]}"; do
|
||||
[ -z "$component" ] && continue
|
||||
if ! rustup component list --toolchain "$TOOLCHAIN" --installed | grep -qE "^${component}(-|$)"; then
|
||||
missing_components+=("$component")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ ${#missing_components[@]} -gt 0 ]; then
|
||||
rustup component add --toolchain "$TOOLCHAIN" "${missing_components[@]}"
|
||||
fi
|
||||
|
||||
if [ -n "$TARGET" ] && ! rustup target list --toolchain "$TOOLCHAIN" --installed | grep -qx "$TARGET"; then
|
||||
rustup target add --toolchain "$TOOLCHAIN" "$TARGET"
|
||||
fi
|
||||
|
||||
toolchain_bin="$(dirname "$(rustup which cargo --toolchain "$TOOLCHAIN")")"
|
||||
echo "$toolchain_bin" >> "$GITHUB_PATH"
|
||||
echo "Using Rust toolchain: $(rustc +"$TOOLCHAIN" --version)"
|
||||
@@ -0,0 +1,47 @@
|
||||
name: "ensure sccache"
|
||||
description: Ensure a pinned sccache binary is on PATH.
|
||||
|
||||
inputs:
|
||||
version:
|
||||
required: false
|
||||
default: "0.15.0"
|
||||
description: sccache release version without the leading v
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
env:
|
||||
SCCACHE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v sccache >/dev/null 2>&1; then
|
||||
current="$(sccache --version | awk '{print $2}')"
|
||||
if [ "$current" = "$SCCACHE_VERSION" ]; then
|
||||
echo "Using baked sccache $current"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Linux-x86_64) triple=x86_64-unknown-linux-musl ;;
|
||||
Darwin-arm64) triple=aarch64-apple-darwin ;;
|
||||
Darwin-x86_64) triple=x86_64-apple-darwin ;;
|
||||
*) triple="" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$triple" ]; then
|
||||
url="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-${triple}.tar.gz"
|
||||
tmpdir="${RUNNER_TEMP:-/tmp}/sccache-${SCCACHE_VERSION}"
|
||||
bindir="${HOME}/.local/bin"
|
||||
rm -rf "$tmpdir"
|
||||
mkdir -p "$tmpdir" "$bindir"
|
||||
curl -fsSL "$url" | tar -xz -C "$tmpdir"
|
||||
install -m755 "$tmpdir"/sccache-v${SCCACHE_VERSION}-${triple}/sccache "$bindir/sccache"
|
||||
echo "$bindir" >> "$GITHUB_PATH"
|
||||
echo "Installed sccache $("$bindir/sccache" --version)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cargo install --locked sccache --version "$SCCACHE_VERSION"
|
||||
echo "Installed sccache $(sccache --version)"
|
||||
@@ -0,0 +1,39 @@
|
||||
name: "ensure zig"
|
||||
description: Ensure a pinned Zig binary is on PATH.
|
||||
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
description: Zig release version
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
env:
|
||||
ZIG_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v zig >/dev/null 2>&1 && [ "$(zig version)" = "$ZIG_VERSION" ]; then
|
||||
echo "Using baked zig $ZIG_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Linux-x86_64) archive="zig-x86_64-linux-${ZIG_VERSION}" ;;
|
||||
Darwin-arm64) archive="zig-aarch64-macos-${ZIG_VERSION}" ;;
|
||||
Darwin-x86_64) archive="zig-x86_64-macos-${ZIG_VERSION}" ;;
|
||||
*)
|
||||
echo "Unsupported zig host: $(uname -s)-$(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
destdir="${HOME}/.local"
|
||||
mkdir -p "$destdir"
|
||||
rm -rf "${destdir:?}/${archive}"
|
||||
curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/${archive}.tar.xz" -o "${destdir}/zig.tar.xz"
|
||||
tar -xJf "${destdir}/zig.tar.xz" -C "$destdir"
|
||||
rm -f "${destdir}/zig.tar.xz"
|
||||
echo "${destdir}/${archive}" >> "$GITHUB_PATH"
|
||||
echo "Installed zig $("${destdir}/${archive}/zig" version)"
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Setup system deps
|
||||
description: >-
|
||||
Install the canvas/native runtime deps CI needs (cairo/pango stack, fd,
|
||||
ripgrep, imagemagick). No-op on the preloaded omp-kata runner image, which
|
||||
already ships them; self-heals on a stock runner by installing via apt.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install system deps (skip when preloaded)
|
||||
shell: bash
|
||||
run: |
|
||||
# The preloaded omp-kata runner image bakes these in. Detect that
|
||||
# and skip the apt round-trip; otherwise install the exact same set so
|
||||
# stock runners (and any future host) still work.
|
||||
if command -v fd >/dev/null 2>&1 \
|
||||
&& command -v rg >/dev/null 2>&1 \
|
||||
&& command -v magick >/dev/null 2>&1 \
|
||||
&& pkg-config --exists cairo pango 2>/dev/null; then
|
||||
echo "System deps already present (preloaded runner image); skipping apt."
|
||||
exit 0
|
||||
fi
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
+166
-182
@@ -26,6 +26,16 @@ concurrency:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
# glibc floor for shipped linux native addons. The omp-kata runner image is
|
||||
# Ubuntu 24.04 (glibc 2.39); a plain native build links 2.39 symbol versions
|
||||
# and fails to dlopen on older distros. Building the linux-gnu addons through
|
||||
# cargo-zigbuild against this floor keeps them portable. Bump to raise the
|
||||
# minimum supported glibc.
|
||||
GLIBC_FLOOR: "2.17"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# scripts/release.ts pushes the version-bump commit and its `v*` tag
|
||||
@@ -40,7 +50,7 @@ jobs:
|
||||
# ref (or from a tagged main HEAD) is also treated as a release.
|
||||
release_metadata:
|
||||
name: Resolve release metadata
|
||||
runs-on: omp-kata
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
outputs:
|
||||
is-release: ${{ steps.detect.outputs.is-release }}
|
||||
release-tag: ${{ steps.detect.outputs.release-tag }}
|
||||
@@ -92,7 +102,7 @@ jobs:
|
||||
# retention window (see build-native action) is the effective TTL.
|
||||
native_artifact_lookup:
|
||||
name: Look up cached native artifacts
|
||||
runs-on: omp-kata
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
outputs:
|
||||
source-hash: ${{ steps.compute.outputs.source-hash }}
|
||||
linux-x64-run-id: ${{ steps.find.outputs.linux-x64-run-id }}
|
||||
@@ -183,18 +193,10 @@ jobs:
|
||||
# Fast lint, type check, and browser bundle build (no Rust, no native build needed)
|
||||
check:
|
||||
name: Lint, type check & web build
|
||||
runs-on: omp-kata
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- run: bun install --frozen-lockfile
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Type check workspace
|
||||
run: bun run ci:check:full
|
||||
- name: Build collab web
|
||||
@@ -207,7 +209,7 @@ jobs:
|
||||
name: "Native: Linux x64 (${{ matrix.variant }})"
|
||||
needs: [release_metadata, native_artifact_lookup]
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' || needs.native_artifact_lookup.outputs.linux-x64-run-id == '' }}
|
||||
runs-on: omp-kata
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -222,13 +224,14 @@ jobs:
|
||||
platform: linux
|
||||
arch: x64
|
||||
variant: ${{ matrix.variant }}
|
||||
glibc: ${{ env.GLIBC_FLOOR }}
|
||||
rust_checks: ${{ matrix.rust_checks && 'true' || 'false' }}
|
||||
save_cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
# Pre-warm the cross-platform native build cache on `main`, in addition to
|
||||
# building the artifacts that ship in releases. Skipped on main when
|
||||
# native_artifact_lookup already found a recent run with all artifacts intact.
|
||||
native_cross_platform:
|
||||
native_cross_platform_kata:
|
||||
name: "Native: ${{ matrix.platform }} ${{ matrix.arch }}"
|
||||
needs: [release_metadata, native_artifact_lookup]
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.native_artifact_lookup.outputs.cross-platform-run-id == '') }}
|
||||
@@ -237,8 +240,6 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- { os: omp-kata, platform: linux, arch: arm64, target: aarch64-unknown-linux-gnu }
|
||||
- { os: macos-15-intel, platform: darwin, arch: x64, variant: baseline }
|
||||
- { os: macos-14, platform: darwin, arch: arm64 }
|
||||
- { os: omp-kata, platform: win32, arch: x64, target: x86_64-pc-windows-msvc, variant: baseline }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
@@ -250,71 +251,103 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
variant: ${{ matrix.variant }}
|
||||
target: ${{ matrix.target }}
|
||||
glibc: ${{ matrix.platform == 'linux' && env.GLIBC_FLOOR || '' }}
|
||||
save_cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
native_cross_platform_macos:
|
||||
name: "Native: ${{ matrix.platform }} ${{ matrix.arch }}"
|
||||
needs: [release_metadata, native_artifact_lookup]
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.native_artifact_lookup.outputs.cross-platform-run-id == '') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-15-intel, platform: darwin, arch: x64, variant: baseline }
|
||||
- { os: macos-14, platform: darwin, arch: arm64 }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/build-native
|
||||
with:
|
||||
hash: ${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
platform: ${{ matrix.platform }}
|
||||
arch: ${{ matrix.arch }}
|
||||
variant: ${{ matrix.variant }}
|
||||
target: ${{ matrix.target }}
|
||||
save_cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
test_workspace:
|
||||
name: Test TS workspace fast
|
||||
runs-on: omp-kata
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
needs: [native_linux_x64, native_artifact_lookup]
|
||||
if: ${{ !cancelled() && needs.native_linux_x64.result != 'failure' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
id: source
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ needs.native_linux_x64.result }}" = "success" ]; then
|
||||
echo "artifact-run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "artifact-run-id=${{ needs.native_artifact_lookup.outputs.linux-x64-run-id }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Download native addons
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- run: bun install --frozen-lockfile
|
||||
- name: Test pure workspaces and repo scripts (TS)
|
||||
# Keep this job native-free and parallel: it deliberately excludes
|
||||
# coding-agent plus native/TUI/browser-ish/integration packages.
|
||||
pattern: pi-natives-linux-x64-*-h${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
path: packages/natives/native
|
||||
merge-multiple: true
|
||||
run-id: ${{ steps.source.outputs.artifact-run-id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Test workspace packages and repo scripts (TS)
|
||||
run: bun run ci:test:ts:workspace
|
||||
|
||||
test_coding_agent_fast:
|
||||
name: Test coding-agent fast (TS)
|
||||
runs-on: omp-kata
|
||||
test_coding_agent_singleton:
|
||||
name: Test coding-agent singleton/global-state (TS)
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
needs: [native_linux_x64, native_artifact_lookup]
|
||||
if: ${{ !cancelled() && needs.native_linux_x64.result != 'failure' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
id: source
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ needs.native_linux_x64.result }}" = "success" ]; then
|
||||
echo "artifact-run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "artifact-run-id=${{ needs.native_artifact_lookup.outputs.linux-x64-run-id }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Download native addons
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- run: bun install --frozen-lockfile
|
||||
- name: Test coding-agent fast unit bucket
|
||||
# The fast bucket is the conservative native-free complement. It has
|
||||
# an explicit worker cap forever; never use package-wide fan-out here.
|
||||
run: bun run ci:test:coding-agent:fast
|
||||
pattern: pi-natives-linux-x64-*-h${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
path: packages/natives/native
|
||||
merge-multiple: true
|
||||
run-id: ${{ steps.source.outputs.artifact-run-id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Test coding-agent singleton/global-state bucket
|
||||
# Keep global Settings/env/fake-timer tests serial; native addon
|
||||
# artifacts are still available like every other coding-agent bucket.
|
||||
run: bun run ci:test:coding-agent:singleton
|
||||
|
||||
test_ts_native:
|
||||
name: Test TS native/integration packages
|
||||
runs-on: omp-kata
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
needs: [native_linux_x64, native_artifact_lookup]
|
||||
if: ${{ !cancelled() && needs.native_linux_x64.result != 'failure' }}
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
id: source
|
||||
shell: bash
|
||||
@@ -336,28 +369,16 @@ jobs:
|
||||
run: bun run ci:test:ts:native
|
||||
|
||||
test_coding_agent_ui:
|
||||
name: Test coding-agent UI/stateful (TS)
|
||||
runs-on: omp-kata
|
||||
name: Test coding-agent UI/TUI (TS)
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
needs: [native_linux_x64, native_artifact_lookup]
|
||||
if: ${{ !cancelled() && needs.native_linux_x64.result != 'failure' }}
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
id: source
|
||||
shell: bash
|
||||
@@ -379,28 +400,15 @@ jobs:
|
||||
run: bun run ci:test:coding-agent:ui
|
||||
|
||||
test_coding_agent_runtime:
|
||||
name: Test coding-agent runtime/stateful (TS)
|
||||
runs-on: omp-kata
|
||||
name: Test coding-agent runtime/session (TS)
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
needs: [native_linux_x64, native_artifact_lookup]
|
||||
if: ${{ !cancelled() && needs.native_linux_x64.result != 'failure' }}
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
- run: bun install --frozen-lockfile
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
id: source
|
||||
shell: bash
|
||||
@@ -419,31 +427,21 @@ jobs:
|
||||
run-id: ${{ steps.source.outputs.artifact-run-id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Test coding-agent runtime bucket
|
||||
# Runtime/session tests import native-backed barrels too; keep this
|
||||
# separate for concurrency, not as a native-free guardrail.
|
||||
run: bun run ci:test:coding-agent:runtime
|
||||
|
||||
test_coding_agent_native:
|
||||
name: Test coding-agent native/tooling (TS)
|
||||
runs-on: omp-kata
|
||||
name: Test coding-agent native/unit (TS)
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
needs: [native_linux_x64, native_artifact_lookup]
|
||||
if: ${{ !cancelled() && needs.native_linux_x64.result != 'failure' }}
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
id: source
|
||||
shell: bash
|
||||
@@ -461,32 +459,20 @@ jobs:
|
||||
merge-multiple: true
|
||||
run-id: ${{ steps.source.outputs.artifact-run-id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Test coding-agent native/tooling bucket
|
||||
- name: Test coding-agent native/unit bucket
|
||||
run: bun run ci:test:coding-agent:native
|
||||
|
||||
test_smoke:
|
||||
name: Test CLI smoke (TS)
|
||||
runs-on: omp-kata
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
needs: [native_linux_x64, native_artifact_lookup]
|
||||
if: ${{ !cancelled() && needs.native_linux_x64.result != 'failure' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
id: source
|
||||
shell: bash
|
||||
@@ -509,68 +495,56 @@ jobs:
|
||||
|
||||
install_methods:
|
||||
name: Install method smoke tests
|
||||
runs-on: omp-kata
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-22.04' || 'omp-kata' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
- uses: ./.github/actions/ensure-rust-toolchain
|
||||
with:
|
||||
toolchain: nightly-2026-04-29
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: ./.github/actions/ensure-sccache
|
||||
with:
|
||||
shared-key: install-methods-linux-x64
|
||||
cache-on-failure: true
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
cache-workspace-crates: true
|
||||
# Layer sccache on top of rust-cache for the same reason as the
|
||||
# build-native action: release version bumps bust the target/ cache,
|
||||
# but sccache hits at the rustc-unit level survive.
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.10
|
||||
version: "0.15.0"
|
||||
- name: Enable sccache for cargo
|
||||
# Conditional backend: self-hosted omp-kata injects a shared S3
|
||||
# (RustFS) sccache via pod env; GitHub-hosted runners keep the GHA
|
||||
# cache. CARGO_INCREMENTAL=0 keeps sccache from silently no-oping.
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_GHA_ENABLED=true"
|
||||
echo "RUSTC_WRAPPER=sccache"
|
||||
echo "CARGO_INCREMENTAL=0"
|
||||
} >> "$GITHUB_ENV"
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick
|
||||
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
||||
sudo ln -sf /usr/bin/convert /usr/local/bin/magick
|
||||
- run: bun install --frozen-lockfile
|
||||
if [ -n "${SCCACHE_BUCKET:-}" ]; then
|
||||
echo "sccache backend: shared S3 ($SCCACHE_BUCKET @ $SCCACHE_ENDPOINT)"
|
||||
else
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
|
||||
echo "sccache backend: GitHub Actions cache"
|
||||
fi
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Install method smoke tests
|
||||
run: bun run ci:test:install-methods
|
||||
|
||||
release_binary:
|
||||
name: "Release binary: ${{ matrix.target_id }}"
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.native_linux_x64.result == 'success' && needs.native_cross_platform.result ==
|
||||
'success' && needs.test_workspace.result == 'success' &&
|
||||
needs.test_coding_agent_fast.result == 'success' &&
|
||||
needs.native_linux_x64.result == 'success' && needs.native_cross_platform_kata.result ==
|
||||
'success' && needs.native_cross_platform_macos.result == 'success' &&
|
||||
needs.test_workspace.result == 'success' &&
|
||||
needs.test_coding_agent_singleton.result == 'success' &&
|
||||
needs.test_ts_native.result == 'success' &&
|
||||
needs.test_coding_agent_ui.result == 'success' &&
|
||||
needs.test_coding_agent_runtime.result == 'success' &&
|
||||
needs.test_coding_agent_native.result == 'success' &&
|
||||
needs.test_smoke.result == 'success' && needs.check.result == 'success' &&
|
||||
needs.install_methods.result == 'success' }}
|
||||
needs: [release_metadata, check, native_linux_x64, native_cross_platform, test_workspace, test_coding_agent_fast, test_ts_native, test_coding_agent_ui, test_coding_agent_runtime, test_coding_agent_native, test_smoke, install_methods, native_artifact_lookup]
|
||||
needs: [release_metadata, check, native_linux_x64, native_cross_platform_kata, native_cross_platform_macos, test_workspace, test_coding_agent_singleton, test_ts_native, test_coding_agent_ui, test_coding_agent_runtime, test_coding_agent_native, test_smoke, install_methods, native_artifact_lookup]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {
|
||||
os: omp-kata,
|
||||
os: ubuntu-22.04,
|
||||
platform: linux,
|
||||
arch: x64,
|
||||
target_id: linux-x64,
|
||||
@@ -598,7 +572,7 @@ jobs:
|
||||
binary_path: packages/coding-agent/binaries/omp-darwin-arm64,
|
||||
}
|
||||
- {
|
||||
os: omp-kata,
|
||||
os: ubuntu-22.04,
|
||||
platform: win32,
|
||||
arch: x64,
|
||||
target_id: win32-x64,
|
||||
@@ -611,11 +585,14 @@ jobs:
|
||||
env:
|
||||
MACOS_SIGNING: ${{ secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' && secrets.APPLE_API_KEY != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- uses: actions/setup-node@v4
|
||||
env:
|
||||
SCCACHE_BUCKET: ""
|
||||
AWS_ACCESS_KEY_ID: ""
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -624,13 +601,13 @@ jobs:
|
||||
if: ${{ !inputs.skip_npm }}
|
||||
run: npm install -g npm@latest
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
- run: bun install --frozen-lockfile
|
||||
- name: Download native addon(s)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*-h${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
path: packages/natives/native
|
||||
@@ -671,7 +648,7 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bun run ci:release:publish-native-leaf ${{ matrix.target_id }}
|
||||
- name: Upload release binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: omp-binary-${{ matrix.target_id }}
|
||||
path: ${{ matrix.binary_path }}
|
||||
@@ -681,24 +658,31 @@ jobs:
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.release_binary.result == 'success' }}
|
||||
needs: [release_metadata, release_binary]
|
||||
runs-on: omp-kata
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Generate release notes from CHANGELOGs
|
||||
env:
|
||||
# `gh release list` (used to find the latest published GitHub
|
||||
# Release tag below the target so silent-tag changelog sections
|
||||
# roll forward — #2596) requires GH_TOKEN in Actions. Without
|
||||
# it gh exits non-zero and the script would degrade to legacy
|
||||
# single-version notes, defeating the recovery.
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: bun scripts/ci-release-notes.ts ${{ needs.release_metadata.outputs.release-tag }}
|
||||
- name: Download release binaries
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: omp-binary-*
|
||||
path: packages/coding-agent/binaries
|
||||
merge-multiple: true
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
with:
|
||||
tag_name: ${{ needs.release_metadata.outputs.release-tag }}
|
||||
files: |
|
||||
@@ -748,7 +732,7 @@ jobs:
|
||||
needs.release_github_verify.result == 'success' &&
|
||||
!inputs.skip_npm }}
|
||||
needs: [release_metadata, release_binary, release_github_verify, native_artifact_lookup]
|
||||
runs-on: omp-kata
|
||||
runs-on: ubuntu-22.04
|
||||
# `id-token: write` lets npm mint the GitHub OIDC token it exchanges for a
|
||||
# short-lived publish token (trusted publishing + provenance). When a
|
||||
# package has no matching trusted publisher configured, npm silently falls
|
||||
@@ -757,11 +741,11 @@ jobs:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -769,7 +753,7 @@ jobs:
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
- name: Cache bun dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
|
||||
@@ -780,7 +764,7 @@ jobs:
|
||||
# Release runs always rebuild natives in this same run, so the
|
||||
# default run-id resolves the artifacts.
|
||||
- name: Download native addons
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: pi-natives-linux-x64-*-h${{ needs.native_artifact_lookup.outputs.source-hash }}
|
||||
path: packages/natives/native
|
||||
@@ -803,19 +787,19 @@ jobs:
|
||||
if: ${{ needs.release_metadata.outputs.is-release == 'true' && !cancelled() &&
|
||||
needs.release_github_verify.result == 'success' }}
|
||||
needs: [release_metadata, release_github_verify]
|
||||
runs-on: omp-kata
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
HAS_TAP_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
if: env.HAS_TAP_KEY == 'true'
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
if: env.HAS_TAP_KEY == 'true'
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
- name: Check out the Homebrew tap
|
||||
if: env.HAS_TAP_KEY == 'true'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
repository: can1357/homebrew-tap
|
||||
ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
|
||||
|
||||
Generated
+4
-4
@@ -2330,7 +2330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "15.13.0"
|
||||
version = "15.13.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -2398,7 +2398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "15.13.0"
|
||||
version = "15.13.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -2410,7 +2410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "15.13.0"
|
||||
version = "15.13.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2458,7 +2458,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "15.13.0"
|
||||
version = "15.13.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-builtins",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "15.13.0"
|
||||
version = "15.13.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
"packages/catalog": {
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -130,7 +130,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"dependencies": {
|
||||
"diff": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
@@ -141,7 +141,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -167,7 +167,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/snapcompact": {
|
||||
"name": "@oh-my-pi/snapcompact",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
@@ -187,7 +187,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -270,7 +270,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"beautiful-mermaid": "catalog:",
|
||||
@@ -284,7 +284,7 @@
|
||||
},
|
||||
"packages/wire": {
|
||||
"name": "@oh-my-pi/pi-wire",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
@@ -320,18 +320,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.13.0",
|
||||
"@oh-my-pi/omp-stats": "15.13.0",
|
||||
"@oh-my-pi/pi-agent-core": "15.13.0",
|
||||
"@oh-my-pi/pi-ai": "15.13.0",
|
||||
"@oh-my-pi/pi-catalog": "15.13.0",
|
||||
"@oh-my-pi/pi-coding-agent": "15.13.0",
|
||||
"@oh-my-pi/pi-mnemopi": "15.13.0",
|
||||
"@oh-my-pi/pi-natives": "15.13.0",
|
||||
"@oh-my-pi/pi-tui": "15.13.0",
|
||||
"@oh-my-pi/pi-utils": "15.13.0",
|
||||
"@oh-my-pi/pi-wire": "15.13.0",
|
||||
"@oh-my-pi/snapcompact": "15.13.0",
|
||||
"@oh-my-pi/hashline": "15.13.1",
|
||||
"@oh-my-pi/omp-stats": "15.13.1",
|
||||
"@oh-my-pi/pi-agent-core": "15.13.1",
|
||||
"@oh-my-pi/pi-ai": "15.13.1",
|
||||
"@oh-my-pi/pi-catalog": "15.13.1",
|
||||
"@oh-my-pi/pi-coding-agent": "15.13.1",
|
||||
"@oh-my-pi/pi-mnemopi": "15.13.1",
|
||||
"@oh-my-pi/pi-natives": "15.13.1",
|
||||
"@oh-my-pi/pi-tui": "15.13.1",
|
||||
"@oh-my-pi/pi-utils": "15.13.1",
|
||||
"@oh-my-pi/pi-wire": "15.13.1",
|
||||
"@oh-my-pi/snapcompact": "15.13.1",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -459,7 +459,7 @@
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
@@ -1011,7 +1011,7 @@
|
||||
|
||||
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.372", "", {}, "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA=="],
|
||||
|
||||
"elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
|
||||
|
||||
@@ -1485,13 +1485,11 @@
|
||||
|
||||
"@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ fn create_windows_napi_tokio_runtime() -> Option<tokio::runtime::Runtime> {
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV15_13_0")]
|
||||
#[napi(js_name = "__piNativesV15_13_1")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
/// Native module entry point: install crash diagnostics before any tool can
|
||||
|
||||
@@ -424,9 +424,13 @@ mod tests {
|
||||
.parse::<i32>()
|
||||
.expect("child pid parses");
|
||||
// SAFETY: `getsid(0)` only queries the current process session; the
|
||||
// return value is checked below.
|
||||
// return value is checked below. Inside a PID namespace (e.g. the
|
||||
// containerized CI runner) the host's session leader can live outside
|
||||
// the namespace, so `getsid(0)` legitimately reports 0 — only -1 is a
|
||||
// real failure. The meaningful invariant is that the child detached
|
||||
// into its own session (`child_sid == child_pid`, distinct from host).
|
||||
let host_sid = unsafe { libc::getsid(0) };
|
||||
assert!(host_sid > 0, "getsid(0) failed: {}", std::io::Error::last_os_error());
|
||||
assert!(host_sid >= 0, "getsid(0) failed: {}", std::io::Error::last_os_error());
|
||||
// SAFETY: `child_pid` is a live positive PID reported by the child; the
|
||||
// return value is checked below.
|
||||
let child_sid = unsafe { libc::getsid(child_pid) };
|
||||
|
||||
@@ -65,6 +65,7 @@ mod platform {
|
||||
|
||||
let mut seen: HashSet<i32> = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
let mut children_file_available = false;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(tid_str) = name.to_str() else {
|
||||
@@ -77,26 +78,56 @@ mod platform {
|
||||
let Ok(content) = fs::read_to_string(&children_path) else {
|
||||
continue;
|
||||
};
|
||||
// The file is readable -> this kernel has CONFIG_PROC_CHILDREN.
|
||||
children_file_available = true;
|
||||
for part in content.split_whitespace() {
|
||||
let Ok(child_pid) = part.parse::<i32>() else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(child_pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(child) = Self::from_pid(child_pid) else {
|
||||
self.push_validated_child(child_pid, &mut seen, &mut out);
|
||||
}
|
||||
}
|
||||
|
||||
// Some Kata / microVM guest kernels are built without CONFIG_PROC_CHILDREN,
|
||||
// so no `.../children` file exists and the walk above finds nothing — which
|
||||
// would silently turn descendant signaling (cancellation cleanup) into a
|
||||
// no-op inside such containers. Fall back to scanning `/proc` and grouping
|
||||
// by parent pid, the same primitive the macOS path uses. Only taken when no
|
||||
// `children` file was readable, so kernels that support it keep the cheap
|
||||
// per-task fast path.
|
||||
if !children_file_available && let Ok(proc_entries) = fs::read_dir("/proc") {
|
||||
for entry in proc_entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(pid_str) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if child.status() == ProcessStatus::Running
|
||||
&& current_parent_pid(child.pid) == Some(self.pid)
|
||||
{
|
||||
out.push(child);
|
||||
}
|
||||
let Ok(child_pid) = pid_str.parse::<i32>() else {
|
||||
continue;
|
||||
};
|
||||
self.push_validated_child(child_pid, &mut seen, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Validate a candidate child pid — dedup, still running, and currently
|
||||
/// parented to `self` — then push it onto `out`. Shared by the
|
||||
/// `/proc/<pid>/task/<tid>/children` fast path and the `/proc`-scan
|
||||
/// fallback for kernels without `CONFIG_PROC_CHILDREN`.
|
||||
fn push_validated_child(&self, child_pid: i32, seen: &mut HashSet<i32>, out: &mut Vec<Self>) {
|
||||
if child_pid == self.pid || !seen.insert(child_pid) {
|
||||
return;
|
||||
}
|
||||
let Some(child) = Self::from_pid(child_pid) else {
|
||||
return;
|
||||
};
|
||||
if child.status() == ProcessStatus::Running
|
||||
&& current_parent_pid(child.pid) == Some(self.pid)
|
||||
{
|
||||
out.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parent_pid(&self) -> Option<i32> {
|
||||
if self.status() == ProcessStatus::Running {
|
||||
current_parent_pid(self.pid)
|
||||
|
||||
@@ -2317,9 +2317,12 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
use std::io::Read as _;
|
||||
|
||||
// SAFETY: `getsid(0)` only queries the current process session; the return
|
||||
// value is checked.
|
||||
// value is checked. Inside a PID namespace (the containerized CI runner)
|
||||
// the host's session leader can live outside the namespace, so `getsid(0)`
|
||||
// legitimately reports 0 — only -1 is a real failure. The child-session
|
||||
// invariants below (own session, distinct from host) stay meaningful.
|
||||
let host_sid = unsafe { libc::getsid(0) };
|
||||
assert!(host_sid > 0, "getsid(0) failed: {}", std::io::Error::last_os_error());
|
||||
assert!(host_sid >= 0, "getsid(0) failed: {}", std::io::Error::last_os_error());
|
||||
|
||||
// Build the same kind of session pi-natives uses in production.
|
||||
let config = ShellConfig { session_env: None, snapshot_path: None, minimizer: None };
|
||||
@@ -2438,9 +2441,12 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
async fn embedded_pipeline_stage_runs_in_its_own_session() {
|
||||
use std::io::Read as _;
|
||||
|
||||
// SAFETY: `getsid(0)` only queries the current process session; checked below.
|
||||
// SAFETY: `getsid(0)` only queries the current process session; checked
|
||||
// below. In a PID namespace (containerized CI) the host's session leader
|
||||
// can live outside the namespace, so `getsid(0)` reports 0, not an error;
|
||||
// only -1 is a real failure.
|
||||
let host_sid = unsafe { libc::getsid(0) };
|
||||
assert!(host_sid > 0, "getsid(0) failed: {}", std::io::Error::last_os_error());
|
||||
assert!(host_sid >= 0, "getsid(0) failed: {}", std::io::Error::last_os_error());
|
||||
|
||||
let config = ShellConfig { session_env: None, snapshot_path: None, minimizer: None };
|
||||
let mut session = create_session(&config).await.expect("create_session");
|
||||
|
||||
@@ -72,6 +72,14 @@ Project-level bases:
|
||||
|
||||
`CONFIG_DIR_NAME` is `.omp` (`packages/utils/src/dirs.ts`).
|
||||
|
||||
## Profiles
|
||||
|
||||
A named profile (`omp --profile <name>`, the `--alias` shortcut, or `OMP_PROFILE` / `PI_PROFILE`) relocates the OMP user base. When a profile is active, every OMP-native user-level path written here as `~/.omp/agent/...` resolves to `~/.omp/profiles/<name>/agent/...` instead.
|
||||
|
||||
The relocation is uniform across the native provider (`builtin.ts`) and the generic `config.ts` helpers, so it covers slash commands, rules, prompts, instructions, hooks, tools, extensions, settings, skills, and MCP, plus the top-level `SYSTEM.md` / `RULES.md` / `AGENTS.md` files and runtime state (sessions, blobs, `agent.db`). A profile sees only its own OMP config, never the default profile's `~/.omp/agent`.
|
||||
|
||||
The other source bases are not profile-scoped and load identically under every profile: the external-tool bases (`~/.claude`, `~/.codex`, `~/.gemini`) belong to those tools, and the project-level bases (`<cwd>/.omp`, `<cwd>/.claude`, ...) are keyed to the working directory. Throughout this document, read `~/.omp/agent` as shorthand for the active profile's agent directory.
|
||||
|
||||
## Important constraint
|
||||
|
||||
The generic helpers in `src/config.ts` do **not** include `.pi` in source discovery order.
|
||||
|
||||
@@ -34,7 +34,7 @@ Native `extension-module` discovery comes from:
|
||||
- User directory: `~/.omp/agent/extensions`
|
||||
- Native legacy/settings JSON entries: `<cwd>/.omp/settings.json#extensions` and `~/.omp/agent/settings.json#extensions`
|
||||
|
||||
Path roots come from the native provider (`SOURCE_PATHS.native`). Project lookup is cwd-only for these native roots; it does not walk ancestors.
|
||||
The project root is the native provider's `.omp` directory (`SOURCE_PATHS.native.projectDir`), cwd-only; it does not walk ancestors. The user root is the active profile's agent directory via `getAgentDir()`, so under `omp --profile <name>` it becomes `~/.omp/profiles/<name>/agent/extensions` (and it honors `PI_CODING_AGENT_DIR`). See [Profiles](./config-usage.md#profiles).
|
||||
|
||||
Notes:
|
||||
|
||||
|
||||
+46
-3
@@ -15,7 +15,7 @@ Source of truth in code:
|
||||
OMP can discover MCP servers from multiple tools (`.claude/`, `.cursor/`, `.vscode/`, `opencode.json`, and more), but for OMP-native configuration you should usually use one of these primary files:
|
||||
|
||||
- Project: `.omp/mcp.json`
|
||||
- User: `~/.omp/agent/mcp.json`
|
||||
- User: `~/.omp/agent/mcp.json` (or `~/.omp/profiles/<name>/agent/mcp.json` when a named profile is active — see [Profiles](#profiles))
|
||||
|
||||
The native provider also reads `.omp/.mcp.json` and `~/.omp/agent/.mcp.json` for compatibility, but OMP writes to the primary `mcp.json` paths above.
|
||||
|
||||
@@ -26,6 +26,19 @@ OMP also accepts fallback standalone files in the project root:
|
||||
|
||||
Use `.omp/mcp.json` or `~/.omp/agent/mcp.json` when you want OMP to own the configuration. Use root `mcp.json` / `.mcp.json` only when you want a portable fallback file that other MCP clients may also read.
|
||||
|
||||
### Profiles
|
||||
|
||||
Named profiles (`omp --profile <name>`, the `--alias` shortcut, or `OMP_PROFILE`/`PI_PROFILE`) isolate user-level MCP config. When a profile is active, the **user** scope resolves to the profile's agent directory instead of the default one:
|
||||
|
||||
- Default profile: `~/.omp/agent/mcp.json`
|
||||
- Profile `<name>`: `~/.omp/profiles/<name>/agent/mcp.json`
|
||||
|
||||
Discovery, the `/mcp` commands, and the config writer all follow the active profile, so a profile sees **only** its own user-level servers — never the default profile's `~/.omp/agent/mcp.json`. Add a server to a profile by launching under it (`omp --profile <name>`) and running `/mcp add` → User level, or by editing `~/.omp/profiles/<name>/agent/mcp.json` directly.
|
||||
|
||||
Project-scoped MCP config (`.omp/mcp.json`) is keyed to the working directory, not the profile, so it applies under every profile. External-tool configs (`.claude/`, `.cursor/`, etc.) are also profile-independent because they belong to those tools rather than to an OMP profile.
|
||||
|
||||
MCP follows the same profile rules as the rest of OMP-native config; see [Configuration Discovery → Profiles](./config-usage.md#profiles).
|
||||
|
||||
## Add a schema reference
|
||||
|
||||
Add this line at the top of the file for editor autocomplete and validation:
|
||||
@@ -61,7 +74,7 @@ Top-level keys:
|
||||
|
||||
- `$schema` — optional JSON Schema URL for tooling
|
||||
- `mcpServers` — map of server name to server config
|
||||
- `disabledServers` — user-level denylist used to turn off discovered servers by name; runtime loading reads this list from `~/.omp/agent/mcp.json`
|
||||
- `disabledServers` — user-level denylist used to turn off discovered servers by name; runtime loading reads this list from the active profile's user MCP file (`~/.omp/agent/mcp.json`, or `~/.omp/profiles/<name>/agent/mcp.json` under a named profile)
|
||||
|
||||
Server names must match `^[a-zA-Z0-9_.-]{1,100}$`.
|
||||
|
||||
@@ -185,6 +198,33 @@ OMP understands two auth-related objects.
|
||||
|
||||
Use this when OMP should remember how to rehydrate credentials for a server.
|
||||
|
||||
You normally do not need to write this block: when OMP completes an OAuth flow
|
||||
for an `http`/`sse` server it stores the credential under a deterministic id
|
||||
derived from the active profile and server URL
|
||||
(`mcp_oauth:profile:<profile>:<url>`), with the refresh material embedded. Any
|
||||
config that points at the same URL — including a *definition-only* entry in a
|
||||
shared project `mcp.json` with no `auth` block at all — resolves the active
|
||||
profile's own credential automatically, including when auth storage is backed by
|
||||
a shared auth broker. This is what makes project-scoped servers safe across
|
||||
profiles: commit the definition, and each profile authorizes (and stays signed
|
||||
in as) its own account via `/mcp reauth <name>`. An explicit `credentialId` is
|
||||
still honored when it resolves; if it points at another profile's row, OMP falls
|
||||
back to the profile-scoped url-keyed binding.
|
||||
|
||||
`/mcp reauth` on a definition-only entry leaves the file untouched — the
|
||||
credential (refresh material included) lives entirely in the active profile's
|
||||
auth storage (local `agent.db` or broker), so a committed project config never
|
||||
picks up local auth state. An explicitly
|
||||
configured `Authorization` header always wins over the url-keyed binding.
|
||||
|
||||
The binding is per profile but not per project: once a profile has authorized
|
||||
a URL, *any* checkout whose `mcp.json` defines a server at that URL connects
|
||||
with that profile's credential automatically. Committed MCP definitions are
|
||||
trusted input — the same already applies to `stdio` entries, which run
|
||||
arbitrary commands — so review a repository's `mcp.json` before opening it
|
||||
with a profile that holds credentials you care about, or use a dedicated
|
||||
profile for untrusted checkouts.
|
||||
|
||||
### `oauth`
|
||||
|
||||
```json
|
||||
@@ -193,12 +233,15 @@ Use this when OMP should remember how to rehydrate credentials for a server.
|
||||
"clientSecret": "...",
|
||||
"redirectUri": "...",
|
||||
"callbackPort": 3334,
|
||||
"callbackPath": "/oauth/callback"
|
||||
"callbackPath": "/oauth/callback",
|
||||
"prompt": "consent"
|
||||
}
|
||||
```
|
||||
|
||||
Use this when the MCP server requires explicit OAuth client settings.
|
||||
|
||||
`prompt` controls the OAuth `prompt` parameter sent with the authorization request. It defaults to `"consent"` so the provider always shows its consent/account screen — without it, a provider with an active browser session silently re-approves the same account, making it impossible to switch accounts or workspaces when reauthorizing (e.g. to use a different Linear workspace per OMP profile). Set it to `""` to omit the parameter for providers that reject it, or to another value the provider understands (e.g. `"select_account"`).
|
||||
|
||||
Slack is the clearest current example. Slack's MCP server is hosted at `https://mcp.slack.com/mcp`, uses Streamable HTTP, and requires confidential OAuth with your Slack app's client credentials.
|
||||
|
||||
Example:
|
||||
|
||||
+1
-1
@@ -417,7 +417,7 @@ Resolution precedence for exact selectors:
|
||||
|
||||
Supported model roles:
|
||||
|
||||
- `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `task`
|
||||
- `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `title`, `task`
|
||||
|
||||
Role aliases like `pi/smol` expand through `settings.modelRoles`. Each role value can also append a thinking selector such as `:minimal`, `:low`, `:medium`, or `:high`.
|
||||
|
||||
|
||||
+1
-1
@@ -299,7 +299,7 @@ enabledModels:
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `modelRoles` | record | `{}` | Map of role name -> model id. Built-in roles: `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `task`. Per-role env/flags: `--model`/`--smol`/`--slow`/`--plan`. |
|
||||
| `modelRoles` | record | `{}` | Map of role name -> model id. Built-in roles: `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `title`, `task`. Per-role env/flags: `--model`/`--smol`/`--slow`/`--plan`. |
|
||||
| `modelTags` | record | `{}` | Custom role/tag metadata; can introduce additional roles. |
|
||||
| `modelProviderOrder` | array | `[]` | Preferred provider order when a model id is ambiguous. |
|
||||
| `cycleOrder` | array | `["smol","default","slow"]` | Roles cycled by the model switcher. |
|
||||
|
||||
@@ -167,7 +167,7 @@ The Extensions dashboard also loads `slash-commands` capability and displays act
|
||||
|
||||
1. **Extension commands** (`#tryExecuteExtensionCommand`)
|
||||
If `/name` matches extension-registered command, handler executes immediately and prompt returns.
|
||||
2. **TypeScript custom commands and MCP prompt commands** (`#tryExecuteCustomCommand`)
|
||||
2. **TypeScript custom commands and MCP prompt commands** (`#tryExecuteCustomCommand`)
|
||||
Boundary only: if matched, it executes and may return:
|
||||
- `string` -> replace prompt text with that string
|
||||
- `void/undefined` -> treated as handled; no LLM prompt
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
# 01 — Host preparation & single-node k3s cluster
|
||||
|
||||
This guide takes a fresh Linux host from bare OS to a **working single-node [k3s](https://k3s.io) cluster** that is ready to run Kata-isolated CI runners. It covers host prerequisites (hardware virtualization, kernel modules, time sync, the nginx port constraint), the exact k3s install, kubeconfig setup, cluster networking (Flannel CNI, CIDRs, CoreDNS), and pod-to-internet egress via host firewalld NAT.
|
||||
|
||||
Read [README.md](README.md) first for the overall architecture and the placeholder/redaction table. When this guide is done, continue with **[02-kata-runtime.md](02-kata-runtime.md)** to install the Kata Containers runtime and register the `kata-qemu` RuntimeClass.
|
||||
|
||||
All configs below are copied from the live reference host and then redacted. Substitute the placeholders from the [README redaction table](README.md#redaction--placeholders) (notably `<CI_HOST>`, `<PUBLIC_IP>`, `<EXT_IFACE>`, `<TAILNET_IP>`) with your own values. CIDRs (`10.42.0.0/16`, `10.43.0.0/16`), the CoreDNS IP (`10.43.0.10`), and version numbers are kept as-is.
|
||||
|
||||
The reference host is a bare-metal **CentOS Stream 10** box, 32 vCPU / 125 GiB RAM, AMD CPU, running **k3s `v1.35.5+k3s1`** (bundled containerd `v2.2.3-k3s1`). Commands are shown for RHEL-family (`dnf` / `firewalld`); adapt package and firewall commands for your distro.
|
||||
|
||||
---
|
||||
|
||||
## 1. Host prerequisites
|
||||
|
||||
### 1.1 Hardware virtualization / KVM
|
||||
|
||||
Every CI job boots its own QEMU/KVM microVM, so the host **must** expose working KVM. On bare metal this means VT-x (Intel) or AMD-V (AMD) enabled in firmware; on a VM you need working *nested* virtualization.
|
||||
|
||||
Check the CPU virtualization flag (`vmx` = Intel, `svm` = AMD) and that the KVM device and modules are present:
|
||||
|
||||
```bash
|
||||
# CPU supports virtualization? (non-zero count = yes)
|
||||
grep -E -c '(vmx|svm)' /proc/cpuinfo
|
||||
|
||||
# which flavor
|
||||
grep -E -om1 '(vmx|svm)' /proc/cpuinfo # reference host prints: svm (AMD)
|
||||
lscpu | grep -i virtualization
|
||||
|
||||
# /dev/kvm must exist and be accessible
|
||||
ls -l /dev/kvm # crw-rw-rw-. 1 root kvm 10, 232 ... /dev/kvm
|
||||
|
||||
# KVM kernel modules loaded
|
||||
lsmod | grep -E '^kvm' # kvm_amd ... kvm (or kvm_intel on Intel)
|
||||
```
|
||||
|
||||
On the reference host this yields:
|
||||
|
||||
```
|
||||
$ ls -l /dev/kvm
|
||||
crw-rw-rw-. 1 root kvm 10, 232 /dev/kvm
|
||||
|
||||
$ lsmod | grep -E '^kvm'
|
||||
kvm_amd 237568 99
|
||||
kvm 1470464 78 kvm_amd
|
||||
```
|
||||
|
||||
The module loads automatically when the CPU flag is present; if `/dev/kvm` is missing, load it explicitly and persist it:
|
||||
|
||||
```bash
|
||||
modprobe kvm_amd # or: modprobe kvm_intel
|
||||
echo kvm_amd > /etc/modules-load.d/kvm.conf
|
||||
```
|
||||
|
||||
On Debian/Ubuntu you can instead run `kvm-ok` (from the `cpu-checker` package); on RHEL-family the checks above are the equivalent.
|
||||
|
||||
> Kata also needs the `vhost_vsock` and `vhost_net` modules for its agent vsock channel and VM networking. Those are part of the Kata runtime setup and are covered in [02-kata-runtime.md](02-kata-runtime.md); the KVM availability above is the only virtualization prerequisite for this guide.
|
||||
|
||||
### 1.2 Kernel modules and sysctls for k3s networking
|
||||
|
||||
k3s needs the `br_netfilter` and `overlay` modules and a couple of sysctls so that bridged pod traffic is seen by iptables and so the host can route/NAT pod traffic. The k3s systemd unit loads the modules on start (`ExecStartPre=-/sbin/modprobe br_netfilter` / `overlay`) and the installer sets the sysctls, but set them explicitly so they survive reboots and are correct before install:
|
||||
|
||||
```bash
|
||||
cat >/etc/modules-load.d/k3s.conf <<'EOF'
|
||||
br_netfilter
|
||||
overlay
|
||||
EOF
|
||||
modprobe br_netfilter overlay
|
||||
|
||||
cat >/etc/sysctl.d/90-k3s.conf <<'EOF'
|
||||
net.ipv4.ip_forward = 1
|
||||
net.bridge.bridge-nf-call-iptables = 1
|
||||
EOF
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
Verify (these are the live values on the reference host):
|
||||
|
||||
```
|
||||
$ sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables
|
||||
net.ipv4.ip_forward = 1
|
||||
net.bridge.bridge-nf-call-iptables = 1
|
||||
|
||||
$ lsmod | grep -E 'br_netfilter|overlay'
|
||||
br_netfilter 36864 0
|
||||
bridge 409600 1 br_netfilter
|
||||
overlay 229376 49
|
||||
```
|
||||
|
||||
`net.ipv4.ip_forward = 1` is what lets the host route (and NAT — see [section 5](#5-cluster-networking-cni-cidrs--nat-egress)) pod traffic out to the internet.
|
||||
|
||||
### 1.3 SELinux
|
||||
|
||||
The reference host runs SELinux in **Permissive** mode:
|
||||
|
||||
```
|
||||
$ getenforce
|
||||
Permissive
|
||||
```
|
||||
|
||||
The k3s installer installs an SELinux policy (`k3s-selinux`) when SELinux is Enforcing on RHEL-family hosts, so Enforcing also works; Permissive is used here to keep the Kata/QEMU + virtio-fs path unencumbered during bring-up. Pick one consistently — if you run Enforcing, make sure `container-selinux` / `k3s-selinux` are installed (the k3s installer pulls them).
|
||||
|
||||
### 1.4 Base packages
|
||||
|
||||
The k3s install script needs only `curl`; everything else (its own containerd, CNI, kubectl) is bundled. Make sure the host has current packages and the basics:
|
||||
|
||||
```bash
|
||||
dnf -y update
|
||||
dnf -y install curl tar iptables
|
||||
```
|
||||
|
||||
> Do **not** pre-install a separate containerd/Docker for k3s to use — k3s ships and manages its own containerd v2. (A separate Docker install can coexist for *building* the runner image; that is covered in [03-runner-image.md](03-runner-image.md).)
|
||||
|
||||
### 1.5 Time synchronization
|
||||
|
||||
Clock skew breaks TLS to the Kubernetes API and to GitHub. Keep an NTP client running. The reference host uses `chrony`:
|
||||
|
||||
```bash
|
||||
dnf -y install chrony
|
||||
systemctl enable --now chronyd
|
||||
timedatectl # "System clock synchronized: yes", "NTP service: active"
|
||||
```
|
||||
|
||||
### 1.6 The nginx port constraint (why Traefik and servicelb are disabled)
|
||||
|
||||
The reference host **also runs nginx**, which owns ports 80 and 443:
|
||||
|
||||
```
|
||||
$ systemctl is-active nginx
|
||||
active
|
||||
|
||||
$ ss -tlnp | grep -E ':80 |:443 '
|
||||
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",...))
|
||||
LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",...))
|
||||
```
|
||||
|
||||
A default k3s install would deploy **Traefik** (an ingress controller that wants :80/:443) and **servicelb** (the Klipper load-balancer, which binds `LoadBalancer` service ports directly on the host). Both would collide with nginx. We therefore disable both at install time (next section). k3s' own API server listens on **:6443**, which does not conflict with nginx, so the cluster is fully functional without those two add-ons.
|
||||
|
||||
> Note on swap: the reference host has swap enabled (`/dev/md1`, 16 GiB) and k3s runs fine with it. If you prefer the upstream-Kubernetes convention of swap-off, disabling it is also supported — it is not required here.
|
||||
|
||||
---
|
||||
|
||||
## 2. Install k3s
|
||||
|
||||
Install k3s as a single-node server, pinning the version and disabling Traefik and servicelb. This is the exact configuration baked into the reference host's systemd unit:
|
||||
|
||||
```bash
|
||||
curl -sfL https://get.k3s.io | \
|
||||
INSTALL_K3S_VERSION=v1.35.5+k3s1 \
|
||||
INSTALL_K3S_EXEC="server --disable=traefik --disable=servicelb" \
|
||||
sh -
|
||||
```
|
||||
|
||||
What each piece does:
|
||||
|
||||
| Token | Meaning |
|
||||
| --- | --- |
|
||||
| `INSTALL_K3S_VERSION=v1.35.5+k3s1` | Pin the exact k3s release (reproducible installs; omit to track the stable channel). |
|
||||
| `server` | Run this node as a **control-plane + worker** (single-node cluster — it both schedules and runs pods). |
|
||||
| `--disable=traefik` | Do **not** deploy the bundled Traefik ingress controller, so nothing tries to bind host :80/:443 (owned by nginx — see [1.6](#16-the-nginx-port-constraint-why-traefik-and-servicelb-are-disabled)). |
|
||||
| `--disable=servicelb` | Do **not** deploy Klipper servicelb, so `LoadBalancer` services do not bind host ports. Runners need no inbound `LoadBalancer`; the ARC listener reaches GitHub via **outbound** long-poll. |
|
||||
|
||||
Everything else is left at k3s defaults *on purpose* — those defaults are what the rest of this doc set relies on:
|
||||
|
||||
| Default (not overridden) | Value | Why we keep it |
|
||||
| --- | --- | --- |
|
||||
| CNI | **Flannel**, VXLAN backend | Simple single-node overlay; see [section 5](#5-cluster-networking-cni-cidrs--nat-egress). |
|
||||
| `--cluster-cidr` (pod network) | `10.42.0.0/16` | Pod IP range. |
|
||||
| `--service-cidr` (service network) | `10.43.0.0/16` | ClusterIP range. |
|
||||
| `--cluster-dns` (CoreDNS) | `10.43.0.10` | In-cluster DNS resolver. |
|
||||
| Container runtime | bundled **containerd v2** | Kata is wired into *this* containerd in [02-kata-runtime.md](02-kata-runtime.md). |
|
||||
|
||||
Because we do not pass `--node-ip` / `--flannel-iface`, k3s auto-detects the host's primary interface and uses its address as the node IP (the public IPv4 on the reference host). If your host has multiple NICs, set `--node-ip` / `--flannel-iface` explicitly.
|
||||
|
||||
The installer writes the systemd unit `/etc/systemd/system/k3s.service`. On the reference host its `ExecStart` is exactly:
|
||||
|
||||
```ini
|
||||
ExecStartPre=-/sbin/modprobe br_netfilter
|
||||
ExecStartPre=-/sbin/modprobe overlay
|
||||
ExecStart=/usr/local/bin/k3s \
|
||||
server \
|
||||
'--disable=traefik' \
|
||||
'--disable=servicelb' \
|
||||
```
|
||||
|
||||
There is **no** `/etc/rancher/k3s/config.yaml` on the host — the two `--disable` flags above are the *only* customization; everything else is the default set listed above.
|
||||
|
||||
Enable and check the service:
|
||||
|
||||
```bash
|
||||
systemctl enable --now k3s
|
||||
systemctl status k3s --no-pager
|
||||
journalctl -u k3s -f # follow startup logs until the node is Ready
|
||||
```
|
||||
|
||||
Confirm the version:
|
||||
|
||||
```
|
||||
$ k3s --version
|
||||
k3s version v1.35.5+k3s1 (6a4781ad)
|
||||
go version go1.25.9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. kubeconfig
|
||||
|
||||
k3s writes an admin kubeconfig to `/etc/rancher/k3s/k3s.yaml`. Point `kubectl` at it:
|
||||
|
||||
```bash
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
# persist for future shells:
|
||||
echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc
|
||||
```
|
||||
|
||||
The file targets the local API server over loopback (TLS material redacted):
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority-data: <REDACTED>
|
||||
server: https://127.0.0.1:6443
|
||||
name: default
|
||||
contexts:
|
||||
- context:
|
||||
cluster: default
|
||||
user: default
|
||||
name: default
|
||||
current-context: default
|
||||
kind: Config
|
||||
users:
|
||||
- name: default
|
||||
user:
|
||||
client-certificate-data: <REDACTED>
|
||||
client-key-data: <REDACTED>
|
||||
```
|
||||
|
||||
> This kubeconfig embeds cluster-admin credentials. Treat the file as a secret (`chmod 600`, root-only). To administer the cluster from another machine, copy the file and replace `127.0.0.1` with the host's reachable address — on the reference host that is done over **Tailscale** (`tailscale0`), so the API server is never exposed on the public interface. Do not commit this file.
|
||||
|
||||
Quick check that the client can talk to the server:
|
||||
|
||||
```bash
|
||||
kubectl version # client + server versions
|
||||
kubectl cluster-info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Verify the node and system pods
|
||||
|
||||
Wait until the node reports `Ready`:
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide
|
||||
```
|
||||
|
||||
Reference output (redacted — `<CI_HOST>` is the hostname, `<PUBLIC_IP>` the auto-detected node IP):
|
||||
|
||||
```
|
||||
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
|
||||
<CI_HOST> Ready control-plane,master ... v1.35.5+k3s1 <PUBLIC_IP> <none> CentOS Stream 10 (Coughlan) 7.0.10-1.el10.elrepo.x86_64 containerd://2.2.3-k3s1
|
||||
```
|
||||
|
||||
Then confirm the core system pods are running:
|
||||
|
||||
```bash
|
||||
kubectl get pods -A
|
||||
```
|
||||
|
||||
You should see the k3s base set in `kube-system` (these are what remain after disabling Traefik and servicelb):
|
||||
|
||||
```
|
||||
NAMESPACE NAME READY STATUS RESTARTS AGE
|
||||
kube-system coredns-<hash> 1/1 Running 0 ...
|
||||
kube-system local-path-provisioner-<hash> 1/1 Running 0 ...
|
||||
kube-system metrics-server-<hash> 1/1 Running 0 ...
|
||||
```
|
||||
|
||||
`local-path-provisioner` is the default storage class (used later for the RustFS cache PVC in [04-arc-and-caching.md](04-arc-and-caching.md)); `coredns` is cluster DNS; `metrics-server` backs `kubectl top`. There is intentionally **no** `traefik` or `svclb-*` pod.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cluster networking (CNI, CIDRs & NAT egress)
|
||||
|
||||
### 5.1 Flannel CNI
|
||||
|
||||
k3s installs Flannel and writes its CNI config to `/var/lib/rancher/k3s/agent/etc/cni/net.d/10-flannel.conflist`. This file is generated by k3s — copied here verbatim (no secrets; nothing to redact):
|
||||
|
||||
```json
|
||||
{
|
||||
"name":"cbr0",
|
||||
"cniVersion":"1.0.0",
|
||||
"plugins":[
|
||||
{
|
||||
"type":"flannel",
|
||||
"delegate":{
|
||||
"hairpinMode":true,
|
||||
"forceAddress":true,
|
||||
"isDefaultGateway":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type":"portmap",
|
||||
"capabilities":{
|
||||
"portMappings":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type":"bandwidth",
|
||||
"capabilities":{
|
||||
"bandwidth":true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `flannel` delegates to the `bridge` plugin (`cbr0`), with `isDefaultGateway` so the pod's default route points at the node — this is the path pod egress takes to reach the host's NAT.
|
||||
- `portmap` and `bandwidth` are standard chained plugins (host-port mapping and per-pod bandwidth shaping).
|
||||
- The backend is Flannel's default **VXLAN** (we did not override it at install). On a single node, pod-to-pod traffic stays on the local bridge.
|
||||
|
||||
### 5.2 Address ranges
|
||||
|
||||
The cluster uses the k3s defaults — keep these as-is (they are referenced throughout the doc set):
|
||||
|
||||
| Range | CIDR | Notes |
|
||||
| --- | --- | --- |
|
||||
| Pod network (cluster-cidr) | `10.42.0.0/16` | Single node carves a `/24` from this: `kubectl get node -o jsonpath='{.items[0].spec.podCIDR}'` → `10.42.0.0/24`. |
|
||||
| Service network (service-cidr) | `10.43.0.0/16` | ClusterIP services. |
|
||||
| CoreDNS service IP | `10.43.0.10` | Cluster DNS resolver (`kube-dns` Service). |
|
||||
|
||||
Confirm CoreDNS:
|
||||
|
||||
```
|
||||
$ kubectl -n kube-system get svc kube-dns
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
kube-dns ClusterIP 10.43.0.10 <none> 53/UDP,53/TCP,9153/TCP ...
|
||||
```
|
||||
|
||||
### 5.3 Pod-to-internet egress via host firewalld NAT
|
||||
|
||||
Runner microVMs need outbound internet (to reach GitHub, fetch toolchains, etc.) but the host must **not** expose the cluster on its public interface. The path is:
|
||||
|
||||
```
|
||||
pod (10.42.0.0/16) --default route--> cbr0/flannel --> host routing --> firewalld masquerade (SNAT) --> <EXT_IFACE> --> internet (as <PUBLIC_IP>)
|
||||
```
|
||||
|
||||
This relies on `net.ipv4.ip_forward = 1` (set in [1.2](#12-kernel-modules-and-sysctls-for-k3s-networking)) plus firewalld **masquerade** (SNAT) and **forwarding** on the public zone. On the reference host the external interface lives in the default `public` zone with masquerade enabled:
|
||||
|
||||
```
|
||||
$ firewall-cmd --list-all
|
||||
public (default, active)
|
||||
target: default
|
||||
icmp-block-inversion: no
|
||||
interfaces: <EXT_IFACE>
|
||||
sources:
|
||||
services: cockpit dhcpv6-client http https ssh
|
||||
ports:
|
||||
protocols:
|
||||
forward: yes
|
||||
masquerade: yes
|
||||
forward-ports:
|
||||
source-ports:
|
||||
icmp-blocks:
|
||||
rich rules:
|
||||
```
|
||||
|
||||
The two lines that make pod egress work are **`masquerade: yes`** (SNAT pod source IPs to `<PUBLIC_IP>` on the way out) and **`forward: yes`** (allow routing between interfaces). The `services` list (`ssh`, `http`, `https`, `cockpit`, `dhcpv6-client`) is the host's own inbound allow-list and is unrelated to pod egress — note that `http`/`https` here are for the **host nginx**, not k3s.
|
||||
|
||||
The pod and service CIDRs (plus the Tailscale admin interface) are placed in the **trusted** zone so intra-cluster and admin traffic is accepted without per-rule firewalling:
|
||||
|
||||
```
|
||||
$ firewall-cmd --zone=trusted --list-all
|
||||
trusted (active)
|
||||
target: ACCEPT
|
||||
interfaces: tailscale0
|
||||
sources: 10.42.0.0/16 10.43.0.0/16
|
||||
forward: yes
|
||||
masquerade: no
|
||||
...
|
||||
```
|
||||
|
||||
```
|
||||
$ firewall-cmd --get-active-zones
|
||||
public (default)
|
||||
interfaces: <EXT_IFACE>
|
||||
trusted
|
||||
interfaces: tailscale0
|
||||
sources: 10.42.0.0/16 10.43.0.0/16
|
||||
docker # br-* bridges from the separate Docker stack (unrelated to k3s)
|
||||
interfaces: ...
|
||||
```
|
||||
|
||||
To reproduce this NAT setup on a fresh host (replace `<EXT_IFACE>` with your public NIC, e.g. `eth0`):
|
||||
|
||||
```bash
|
||||
# external interface in the public zone with NAT + forwarding
|
||||
firewall-cmd --permanent --zone=public --change-interface=<EXT_IFACE>
|
||||
firewall-cmd --permanent --zone=public --add-masquerade
|
||||
firewall-cmd --permanent --zone=public --add-forward # firewalld >= 0.9
|
||||
|
||||
# trust intra-cluster traffic (pod + service CIDRs) and the Tailscale admin iface
|
||||
firewall-cmd --permanent --zone=trusted --add-source=10.42.0.0/16
|
||||
firewall-cmd --permanent --zone=trusted --add-source=10.43.0.0/16
|
||||
firewall-cmd --permanent --zone=trusted --change-interface=tailscale0
|
||||
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
Verify masquerade and forwarding are live:
|
||||
|
||||
```
|
||||
$ firewall-cmd --query-masquerade
|
||||
yes
|
||||
$ firewall-cmd --query-forward
|
||||
yes
|
||||
```
|
||||
|
||||
> This is host-level NAT only. A second, finer-grained layer — the `runner-egress-lockdown` Kubernetes **NetworkPolicy** — restricts *which* destinations runner pods may reach (it blocks `<PUBLIC_IP>`, the tailnet `100.64.0.0/10`, RFC-1918 ranges, etc., while allowing the public internet and cluster DNS). That policy is part of the runner setup and is documented in [04-arc-and-caching.md](04-arc-and-caching.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification: DNS & egress smoke test
|
||||
|
||||
The system pods being `Running` (section 4) already prove in-cluster networking. To prove **DNS resolution** and **pod-to-internet egress** end to end, run a throwaway pod (delete it afterward):
|
||||
|
||||
```bash
|
||||
# in-cluster DNS: resolve the kubernetes Service via CoreDNS (10.43.0.10)
|
||||
kubectl run dns-test --image=busybox:1.36 --restart=Never --rm -it -- \
|
||||
nslookup kubernetes.default.svc.cluster.local
|
||||
|
||||
# external DNS + egress: resolve and reach the internet through host NAT
|
||||
kubectl run egress-test --image=busybox:1.36 --restart=Never --rm -it -- \
|
||||
sh -c 'nslookup github.com && wget -qO- https://api.github.com/zen'
|
||||
```
|
||||
|
||||
Expected: the first command resolves to a `10.43.x.x` ClusterIP; the second resolves a public name and prints a line of text fetched from the internet (proving SNAT/masquerade works). If DNS fails, recheck CoreDNS (`kubectl -n kube-system get pods`); if egress fails, recheck `ip_forward`, `masquerade`, and `forward` from [section 5.3](#53-pod-to-internet-egress-via-host-firewalld-nat).
|
||||
|
||||
> On the live reference host, the ARC scale-set **listener** pod (in `arc-systems`) is itself continuous proof of working egress: it stays `Running` only because it can reach GitHub outbound through this exact NAT path.
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
The host now runs a healthy single-node k3s cluster with working networking and NAT egress, and Traefik/servicelb disabled so nginx keeps :80/:443. Continue with:
|
||||
|
||||
- **[02-kata-runtime.md](02-kata-runtime.md)** — install Kata Containers, wire it into k3s' bundled containerd, and register the `kata-qemu` RuntimeClass.
|
||||
- [README.md](README.md) — architecture overview, full component map, and the placeholder/redaction reference.
|
||||
@@ -0,0 +1,471 @@
|
||||
# 02 — Kata Containers runtime for k3s
|
||||
|
||||
This guide installs **Kata Containers 3.31.0** and wires it into the k3s-bundled
|
||||
containerd as a named runtime, `kata-qemu`, so that any pod carrying
|
||||
`runtimeClassName: kata-qemu` boots inside its own QEMU/KVM microVM with a
|
||||
**separate guest kernel** from the host.
|
||||
|
||||
- **Previous:** [`01-host-and-cluster.md`](./01-host-and-cluster.md) — host prep, k3s install, networking. You need a working single-node k3s, KVM enabled (`/dev/kvm` present), and nested-virt off (this is bare metal).
|
||||
- **Next:** [`03-runner-image.md`](./03-runner-image.md) — the preloaded GitHub Actions runner image that runs inside these microVMs.
|
||||
|
||||
Why a microVM per pod: the CI jobs run untrusted code (third-party deps, fork
|
||||
PRs). A `runc` container shares the host kernel; a Kata pod gets its own guest
|
||||
kernel and a hardware-virtualization boundary (Intel VT-x / AMD-V), so a kernel
|
||||
exploit inside a job does not reach the host. On `<CI_HOST>` the host runs the
|
||||
CentOS Stream 10 kernel `7.0.10-1.el10.elrepo.x86_64`, while every Kata guest
|
||||
runs `6.18.28-194` — the verification in the last section turns that gap into a
|
||||
one-line proof.
|
||||
|
||||
All host paths and commands below were taken from the live host (read-only) and
|
||||
redacted per the doc-set redaction map. Run them on **your** reproduction host;
|
||||
on the live host only the read-only inspections (`kata-runtime check`,
|
||||
`kata-runtime env`, `kubectl get …`) are safe.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Install the Kata static release into `/opt/kata`
|
||||
|
||||
Kata ships a self-contained **static tarball**: a pinned QEMU, the guest kernel,
|
||||
the guest rootfs image, virtiofsd, the runtime, and the containerd shim, all
|
||||
rooted at `/opt/kata`. Nothing links against host libraries, so it coexists
|
||||
cleanly with the host's own QEMU/libvirt and survives OS upgrades.
|
||||
|
||||
```bash
|
||||
# amd64 host; pin the exact version so the kernel/image/QEMU triple is reproducible.
|
||||
KATA_VER=3.31.0
|
||||
curl -fsSL -o kata-static.tar.xz \
|
||||
"https://github.com/kata-containers/kata-containers/releases/download/${KATA_VER}/kata-static-${KATA_VER}-amd64.tar.xz"
|
||||
|
||||
# The archive is rooted at ./opt/kata, so extracting at / lands everything in /opt/kata.
|
||||
sudo tar -xf kata-static.tar.xz -C /
|
||||
```
|
||||
|
||||
Put the **shim** and the **CLI** on `PATH`. The shim symlink is what containerd
|
||||
resolves at launch time (see Step 2); the `kata-runtime` symlink is for
|
||||
host-side inspection and `kata-runtime check`/`env`:
|
||||
|
||||
```bash
|
||||
sudo ln -sf /opt/kata/bin/containerd-shim-kata-v2 /usr/local/bin/containerd-shim-kata-v2
|
||||
sudo ln -sf /opt/kata/bin/kata-runtime /usr/local/bin/kata-runtime
|
||||
```
|
||||
|
||||
On the live host both symlinks are in place:
|
||||
|
||||
```text
|
||||
/usr/local/bin/containerd-shim-kata-v2 -> /opt/kata/bin/containerd-shim-kata-v2
|
||||
/usr/local/bin/kata-runtime -> /opt/kata/bin/kata-runtime
|
||||
```
|
||||
|
||||
### Inspect the install layout
|
||||
|
||||
```text
|
||||
/opt/kata
|
||||
├── VERSION # "3.31.0"
|
||||
├── versions.yaml # pinned component versions (QEMU, kernel, rootfs)
|
||||
├── bin/ # qemu-system-x86_64, containerd-shim-kata-v2, kata-runtime, ...
|
||||
├── libexec/ # virtiofsd
|
||||
└── share/
|
||||
├── defaults/kata-containers/
|
||||
│ ├── configuration-qemu.toml # the active config for the kata-qemu runtime
|
||||
│ └── configuration.toml -> configuration-qemu.toml
|
||||
└── kata-containers/
|
||||
├── vmlinux.container -> vmlinux-6.18.28-194 # guest kernel
|
||||
└── kata-containers.img -> kata-ubuntu-noble.image # guest rootfs
|
||||
```
|
||||
|
||||
`bin/` ships several hypervisors (`qemu-system-x86_64`, `cloud-hypervisor`,
|
||||
`firecracker`, `jailer`) and the QEMU confidential-computing variants
|
||||
(`-snp-experimental`, `-tdx-experimental`); this setup uses plain
|
||||
`qemu-system-x86_64`. `share/defaults/kata-containers/` also carries
|
||||
`configuration-clh.toml`, `configuration-fc.toml`, etc. — one per hypervisor.
|
||||
We only use `configuration-qemu.toml`.
|
||||
|
||||
### Confirm version and capability
|
||||
|
||||
```console
|
||||
$ /opt/kata/bin/kata-runtime --version
|
||||
kata-runtime : 3.31.0
|
||||
commit : ddb8a5de89891f12e1ce0013eb066a330b2988b9
|
||||
OCI specs: 1.2.1
|
||||
```
|
||||
|
||||
The component pins live in `/opt/kata/versions.yaml`. The two that matter for
|
||||
the guest are the QEMU and kernel versions:
|
||||
|
||||
```yaml
|
||||
assets:
|
||||
hypervisor:
|
||||
qemu:
|
||||
version: "v10.2.1"
|
||||
kernel:
|
||||
version: "v6.18.28"
|
||||
```
|
||||
|
||||
`kata-runtime check` confirms the host can actually start a microVM (KVM
|
||||
present, CPU virtualization usable). Run it as a host-side smoke test before
|
||||
touching k3s:
|
||||
|
||||
```console
|
||||
$ /opt/kata/bin/kata-runtime check
|
||||
level=warning msg="Not running network checks as super user" arch=amd64 ...
|
||||
System is capable of running Kata Containers
|
||||
System can currently create Kata Containers
|
||||
```
|
||||
|
||||
`kata-runtime env` cross-checks the resolved kernel/image/hypervisor and the
|
||||
host kernel — note the guest kernel (`6.18.28-194`) versus the host kernel
|
||||
(`7.0.10`):
|
||||
|
||||
```console
|
||||
$ /opt/kata/bin/kata-runtime env | grep -iE 'Kernel|Image|MachineType|Path|Hypervisor'
|
||||
[Kernel]
|
||||
Path = "/opt/kata/share/kata-containers/vmlinux-6.18.28-194"
|
||||
[Image]
|
||||
Path = "/opt/kata/share/kata-containers/kata-ubuntu-noble.image"
|
||||
[Hypervisor]
|
||||
MachineType = "q35"
|
||||
Version = "QEMU emulator version 10.2.1 (kata-static) ..."
|
||||
Path = "/opt/kata/bin/qemu-system-x86_64"
|
||||
[Host]
|
||||
Kernel = "7.0.10-1.el10.elrepo.x86_64"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Register `kata-qemu` with k3s's containerd
|
||||
|
||||
k3s embeds its **own** containerd (v2 here) — not the host's. On every start it
|
||||
**regenerates** `/var/lib/rancher/k3s/agent/etc/containerd/config.toml` from a
|
||||
template; the header says so:
|
||||
|
||||
```toml
|
||||
# File generated by k3s. DO NOT EDIT. Use config-v3.toml.tmpl instead.
|
||||
version = 3
|
||||
imports = ["/var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.d/*.toml"]
|
||||
root = "/var/lib/rancher/k3s/agent/containerd"
|
||||
state = "/run/k3s/containerd"
|
||||
...
|
||||
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc]
|
||||
runtime_type = "io.containerd.runc.v2"
|
||||
```
|
||||
|
||||
Editing `config.toml` directly is pointless — k3s overwrites it on the next
|
||||
restart. The durable hook is the generated `imports` line: k3s merges any
|
||||
`*.toml` under `config-v3.toml.d/` into the final config. That directory is the
|
||||
**supported drop-in path** and survives k3s upgrades.
|
||||
|
||||
> k3s picks the drop-in directory from the containerd config schema version. With
|
||||
> the generated `version = 3` config the path is `config-v3.toml.d/`. (On older
|
||||
> k3s that emitted a v2 config it was `config.toml.d/`.) Match whatever your
|
||||
> generated `config.toml` declares.
|
||||
|
||||
Create the drop-in. This is the real file from the host, verbatim:
|
||||
|
||||
```toml
|
||||
# /var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.d/kata.toml
|
||||
# Kata Containers (QEMU/KVM microVM) runtime for k3s containerd.
|
||||
# Added out-of-band; merged via the generated config's `imports`.
|
||||
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.kata-qemu]
|
||||
runtime_type = "io.containerd.kata.v2"
|
||||
runtime_path = "/opt/kata/bin/containerd-shim-kata-v2"
|
||||
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.kata-qemu.options]
|
||||
ConfigPath = "/opt/kata/share/defaults/kata-containers/configuration-qemu.toml"
|
||||
```
|
||||
|
||||
What each line does:
|
||||
|
||||
- The table key `…runtimes.kata-qemu` defines a CRI runtime **named**
|
||||
`kata-qemu`. A RuntimeClass whose `handler` is `kata-qemu` (Step 3) selects
|
||||
exactly this entry.
|
||||
- `runtime_type = "io.containerd.kata.v2"` tells containerd this is a v2 (shim)
|
||||
runtime. By the v2 naming convention containerd would look for
|
||||
`containerd-shim-kata-v2` on `PATH` — which is why we symlinked it in Step 1.
|
||||
- `runtime_path = "/opt/kata/bin/containerd-shim-kata-v2"` pins the **exact**
|
||||
shim binary regardless of `PATH`, so the runtime is unambiguous even if the
|
||||
symlink is missing or another shim shadows it.
|
||||
- `options.ConfigPath` points the shim at the hypervisor configuration analyzed
|
||||
in Step 4. This is how a single shim binary can back multiple runtimes (e.g. a
|
||||
second `kata-clh` runtime pointing at `configuration-clh.toml`).
|
||||
|
||||
Restart k3s so the bundled containerd reloads and merges the drop-in:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart k3s
|
||||
```
|
||||
|
||||
Verify containerd now knows the runtime (CRI reports the registered runtime
|
||||
handlers):
|
||||
|
||||
```bash
|
||||
sudo k3s crictl info | grep -A2 '"kata-qemu"'
|
||||
```
|
||||
|
||||
The `runc` default is untouched: pods without a `runtimeClassName` keep running
|
||||
as ordinary host-kernel containers. Only pods that opt into the RuntimeClass
|
||||
below get a microVM.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Create the `kata-qemu` RuntimeClass
|
||||
|
||||
A Kubernetes [RuntimeClass](https://kubernetes.io/docs/concepts/containers/runtime-class/)
|
||||
maps a friendly name a pod can request to the containerd runtime handler
|
||||
registered in Step 2. The `handler` value **must** equal the runtime name in the
|
||||
drop-in (`kata-qemu`).
|
||||
|
||||
```yaml
|
||||
# kata-qemu-runtimeclass.yaml
|
||||
apiVersion: node.k8s.io/v1
|
||||
kind: RuntimeClass
|
||||
metadata:
|
||||
name: kata-qemu
|
||||
handler: kata-qemu
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f kata-qemu-runtimeclass.yaml
|
||||
```
|
||||
|
||||
Confirm it exists (live host, read-only):
|
||||
|
||||
```console
|
||||
$ kubectl get runtimeclass kata-qemu -o yaml
|
||||
apiVersion: node.k8s.io/v1
|
||||
handler: kata-qemu
|
||||
kind: RuntimeClass
|
||||
metadata:
|
||||
annotations:
|
||||
kubectl.kubernetes.io/last-applied-configuration: |
|
||||
{"apiVersion":"node.k8s.io/v1","handler":"kata-qemu","kind":"RuntimeClass","metadata":{"annotations":{},"name":"kata-qemu"}}
|
||||
creationTimestamp: "2026-06-14T19:18:16Z"
|
||||
name: kata-qemu
|
||||
resourceVersion: "580"
|
||||
uid: 50a199ac-3936-4ae8-9deb-be389ce9f042
|
||||
```
|
||||
|
||||
From here, any pod with `spec.runtimeClassName: kata-qemu` is scheduled onto the
|
||||
Kata shim. The ARC runner pod template sets exactly this — see
|
||||
[`04-arc-and-caching.md`](./04-arc-and-caching.md).
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Kata hypervisor configuration (`configuration-qemu.toml`)
|
||||
|
||||
The shim reads `/opt/kata/share/defaults/kata-containers/configuration-qemu.toml`
|
||||
(via `ConfigPath`). The file is long and mostly defaults; below are the **active
|
||||
settings that shape this deployment**, copied from the host with the noise
|
||||
stripped. Each is explained afterward.
|
||||
|
||||
```toml
|
||||
[hypervisor.qemu]
|
||||
path = "/opt/kata/bin/qemu-system-x86_64"
|
||||
kernel = "/opt/kata/share/kata-containers/vmlinux.container"
|
||||
image = "/opt/kata/share/kata-containers/kata-containers.img"
|
||||
machine_type = "q35"
|
||||
rootfs_type = "ext4"
|
||||
cpu_features = "pmu=off"
|
||||
kernel_params = "cgroup_no_v1=all systemd.unified_cgroup_hierarchy=1"
|
||||
|
||||
default_vcpus = 2
|
||||
default_maxvcpus = 0
|
||||
default_memory = 4096
|
||||
default_maxmemory = 0
|
||||
memory_slots = 10
|
||||
|
||||
shared_fs = "virtio-fs"
|
||||
virtio_fs_daemon = "/opt/kata/libexec/virtiofsd"
|
||||
virtio_fs_cache = "auto"
|
||||
virtio_fs_extra_args = ["--thread-pool-size=4", "--announce-submounts"]
|
||||
|
||||
disable_block_device_use = true
|
||||
block_device_driver = "virtio-scsi"
|
||||
block_device_aio = "io_uring"
|
||||
|
||||
[factory]
|
||||
enable_template = false
|
||||
vm_cache_number = 0
|
||||
|
||||
[runtime]
|
||||
internetworking_model = "tcfilter"
|
||||
emptydir_mode = "shared-fs"
|
||||
static_sandbox_resource_mgmt = false
|
||||
sandbox_cgroup_only = false
|
||||
```
|
||||
|
||||
### Boot artifacts: `path` / `kernel` / `image`
|
||||
|
||||
`path` is the bundled QEMU 10.2.1; `kernel` is the guest kernel
|
||||
(`vmlinux.container -> vmlinux-6.18.28-194`); `image` is the guest rootfs
|
||||
(`kata-containers.img -> kata-ubuntu-noble.image`, an Ubuntu Noble rootfs with
|
||||
`kata-agent` baked in as PID 1's manager). These three are the entire guest —
|
||||
none of them is the host kernel, which is the whole point. `machine_type =
|
||||
"q35"` is the modern PCIe QEMU machine (needed for PCIe hotplug);
|
||||
`cpu_features = "pmu=off"` disables the virtual perf-monitoring unit (avoids
|
||||
spurious PMU passthrough issues). `kernel_params` forces cgroup v2-only in the
|
||||
guest, matching a modern systemd userspace.
|
||||
|
||||
### vCPU / memory sizing — hotplug from pod requests/limits
|
||||
|
||||
This is the most important block to understand for a CI runner.
|
||||
|
||||
- `default_vcpus = 2` and `default_memory = 4096` (MiB) are the **boot-time**
|
||||
size. Runner microVMs now start at the runner pod's guaranteed request: 2 vCPU,
|
||||
4 GiB, rather than booting tiny and immediately hotplugging to that floor.
|
||||
- `default_maxvcpus = 0` means "no fixed ceiling — use the host's physical CPU
|
||||
count" (32 on this box). `default_maxmemory = 0` likewise means "host total
|
||||
RAM". `memory_slots = 10` is the number of ACPI DIMM hotplug slots, i.e. how
|
||||
many memory-grow operations the guest can accept.
|
||||
- `static_sandbox_resource_mgmt = false` still enables **dynamic** sizing: Kata
|
||||
reads the pod's CPU/memory **limits** that the kubelet/CRI hands the shim and
|
||||
hotplugs beyond the boot floor as needed. So a runner pod requesting `2 CPU /
|
||||
4Gi` with limits `8 CPU / 12Gi` now boots at 2 vCPU/4 GiB and grows toward
|
||||
8 vCPU / 12 GiB. If a pod sets no limits, the VM stays at the defaults.
|
||||
|
||||
The practical rule here is simple: align the defaults to the runner pod's
|
||||
requests when every job creates a fresh VM and immediately needs that baseline
|
||||
anyway; let `resources.limits` remain the hotplug ceiling. The repo ships
|
||||
[`infra/tune-kata-runtime.sh`](../tune-kata-runtime.sh) to apply exactly this
|
||||
change (plus the virtiofsd worker-pool tuning below) over SSH to the host.
|
||||
|
||||
### `shared_fs = "virtio-fs"` — sharing the container rootfs into the VM
|
||||
|
||||
The container's rootfs is prepared on the **host** by containerd's overlayfs
|
||||
snapshotter (see below). Rather than repackaging it as a virtual disk, Kata runs
|
||||
**virtiofsd** (`/opt/kata/libexec/virtiofsd`) on the host to export that
|
||||
directory over **virtio-fs**, and the guest mounts it as the container root.
|
||||
This is why `disable_block_device_use = true`: the rootfs travels in over the
|
||||
shared filesystem, not as a block device. Benefits: no image-to-block
|
||||
conversion, near-instant rootfs availability, and host/guest can both see the
|
||||
files. `virtio_fs_cache = "auto"` keeps the conservative page-cache behavior, but
|
||||
the active worker pool is now `--thread-pool-size=4` rather than `1` so
|
||||
metadata-heavy mounted-cache and dependency-install paths have a few host workers
|
||||
to fan out across. `--announce-submounts` keeps nested mounts visible to the guest.
|
||||
|
||||
`emptydir_mode = "shared-fs"` extends the same mechanism to Kubernetes
|
||||
`emptyDir` volumes — they are shared into the guest over virtio-fs instead of
|
||||
being block devices.
|
||||
|
||||
### `block_device_driver = "virtio-scsi"` — for the volumes that *are* blocks
|
||||
|
||||
Even with `disable_block_device_use = true` for the rootfs, any genuine block
|
||||
volume (e.g. a `local-path` PVC presented as a device) is attached over a
|
||||
**virtio-scsi** controller, with `block_device_aio = "io_uring"` for efficient
|
||||
async I/O. virtio-scsi (vs virtio-blk) supports more disks per controller and
|
||||
hotplug, which matters when volumes attach after boot.
|
||||
|
||||
### vsock agent channel
|
||||
|
||||
The shim on the host talks to `kata-agent` inside the guest over a
|
||||
**VIRTIO-VSOCK** channel — a host↔guest socket transport that needs no guest IP
|
||||
or network. All container lifecycle operations (create/start/exec/IO/metrics)
|
||||
are ttRPC calls over that vsock link. In Kata 3.x vsock is the default and only
|
||||
agent transport, so there is no `use_vsock` toggle to set; the config instead
|
||||
shows `use_legacy_serial = false`, confirming the guest console/agent path is on
|
||||
the modern virtio channel rather than a legacy serial port. The upshot: the
|
||||
agent control plane is isolated from the pod's data-plane networking entirely.
|
||||
|
||||
### Networking into the guest
|
||||
|
||||
`internetworking_model = "tcfilter"` is how the pod's CNI veth reaches the VM:
|
||||
Kata creates a TAP device for the guest NIC and installs a TC (traffic-control)
|
||||
filter that mirrors packets between the CNI-provided veth and the TAP. The pod
|
||||
keeps the IP Flannel assigned it; the VM transparently sits behind it. Egress
|
||||
restrictions are enforced one layer up by a NetworkPolicy — see
|
||||
[`04-arc-and-caching.md`](./04-arc-and-caching.md).
|
||||
|
||||
### `factory.enable_template = false` — a fresh VM per job, deliberately
|
||||
|
||||
Kata's **VM factory/templating** can pre-create a paused "template" VM and fork
|
||||
new microVMs from it via copy-on-write memory, shaving boot time. It is **off**
|
||||
here (`enable_template = false`, `vm_cache_number = 0`) on purpose: CI jobs must
|
||||
be mutually isolated and reproducible, so each job gets a **pristine VM built
|
||||
from scratch** with no memory state inherited from a previous job. The boot cost
|
||||
(a second or two) is an acceptable price for clean isolation, and the preloaded
|
||||
runner image ([`03-runner-image.md`](./03-runner-image.md)) is what removes the
|
||||
*real* per-job cost (dependency installs), not VM templating.
|
||||
|
||||
### Interaction with the overlayfs snapshotter
|
||||
|
||||
k3s's containerd uses the default **overlayfs** snapshotter. For a `runc` pod
|
||||
that overlay mount *is* the container root. For a Kata pod, containerd still
|
||||
builds the same overlayfs rootfs on the host, but because `shared_fs =
|
||||
"virtio-fs"` it is **exported into the guest by virtiofsd** rather than used
|
||||
directly. So the two cooperate cleanly: the snapshotter assembles image layers
|
||||
on the host (image pulls, layer caching, dedup all work normally), and virtio-fs
|
||||
projects the result into the microVM. No special snapshotter (devmapper /
|
||||
blockfile) is needed — that would only be required if you wanted the rootfs
|
||||
delivered as a block device instead of a shared filesystem.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Verify microVM isolation
|
||||
|
||||
The defining test: a pod under `kata-qemu` must report a **different kernel**
|
||||
than the host. Run a throwaway pod with `--rm` so nothing is left behind. On
|
||||
your reproduction host:
|
||||
|
||||
```bash
|
||||
# Inside the microVM (Kata): guest kernel.
|
||||
kubectl run kata-smoke --rm -it --restart=Never \
|
||||
--image=busybox \
|
||||
--overrides='{"spec":{"runtimeClassName":"kata-qemu"}}' \
|
||||
-- uname -r
|
||||
```
|
||||
|
||||
Expected — the **guest** kernel:
|
||||
|
||||
```text
|
||||
6.18.28-194
|
||||
```
|
||||
|
||||
Compare with the host kernel:
|
||||
|
||||
```console
|
||||
$ uname -r
|
||||
7.0.10-1.el10.elrepo.x86_64
|
||||
```
|
||||
|
||||
Different kernel string == the workload is genuinely inside a separate guest
|
||||
kernel, not a namespaced host process. As a control, the same pod **without**
|
||||
the RuntimeClass runs on `runc` and prints the **host** kernel
|
||||
(`7.0.10-1.el10.elrepo.x86_64`) — proving the difference comes from Kata, not
|
||||
the image.
|
||||
|
||||
For a closer look at the VM's resources (confirming the hotplug sizing from Step
|
||||
4), use an image with more tools:
|
||||
|
||||
```bash
|
||||
kubectl run kata-smoke --rm -it --restart=Never \
|
||||
--image=ubuntu:24.04 \
|
||||
--overrides='{"spec":{"runtimeClassName":"kata-qemu"}}' \
|
||||
-- bash -lc 'uname -r; nproc; grep MemTotal /proc/meminfo'
|
||||
```
|
||||
|
||||
This prints the guest kernel, the hotplugged vCPU count, and guest RAM (MiB) —
|
||||
which track the pod's `resources.limits`, not the host's 32c/125G.
|
||||
|
||||
> **On the live host, do not run throwaway pods.** It is production CI. Use the
|
||||
> read-only host checks instead: `kata-runtime check` and `kata-runtime env`
|
||||
> (Step 1) prove the runtime is healthy without scheduling anything. The
|
||||
> equivalent VM-boot proof for the actual runner image is the `preload-verify`
|
||||
> recipe documented in [`03-runner-image.md`](./03-runner-image.md).
|
||||
|
||||
---
|
||||
|
||||
## Recap
|
||||
|
||||
1. Static tarball extracted to `/opt/kata` (QEMU + guest kernel + rootfs +
|
||||
virtiofsd + shim), shim and CLI symlinked onto `PATH`.
|
||||
2. Drop-in `config-v3.toml.d/kata.toml` registers the `kata-qemu` runtime
|
||||
(`io.containerd.kata.v2`, pinned `runtime_path`, `ConfigPath`), merged via
|
||||
k3s's generated `imports`; picked up on `systemctl restart k3s`.
|
||||
3. `RuntimeClass/kata-qemu` (`handler: kata-qemu`) lets pods opt in.
|
||||
4. `configuration-qemu.toml` boots a small QEMU q35 VM (1 vCPU / 2 GiB) that
|
||||
hotplugs up to the pod's limits, shares the container rootfs in over
|
||||
virtio-fs, talks to the agent over vsock, and builds a fresh VM per job
|
||||
(templating off).
|
||||
5. A `kata-qemu` pod reports guest kernel `6.18.28-194` vs host
|
||||
`7.0.10-1.el10.elrepo.x86_64` — isolation confirmed.
|
||||
|
||||
Continue to [`03-runner-image.md`](./03-runner-image.md) to build the runner
|
||||
image that boots inside these microVMs.
|
||||
@@ -0,0 +1,466 @@
|
||||
# 03 - Preloaded runner image
|
||||
|
||||
Each ephemeral CI job boots a fresh Kata microVM and runs inside a single
|
||||
runner container. To keep that cold microVM **warm** (no per-job dependency
|
||||
fetches), the container does not use the stock GitHub Actions runner image
|
||||
directly: it uses a locally built image that bakes every dependency CI installs
|
||||
on every job into the snapshot. This document covers building that image,
|
||||
importing it into k3s containerd, pointing ARC at it, and verifying it.
|
||||
|
||||
Navigation: previous - [02-kata-runtime.md](./02-kata-runtime.md) (Kata +
|
||||
containerd + RuntimeClass) | next - [04-arc-and-caching.md](./04-arc-and-caching.md)
|
||||
(ARC runners, shared caches, egress policy).
|
||||
|
||||
> The image built here is referenced by the ARC runner pod template
|
||||
> (`template.spec.containers[0].image` + `imagePullPolicy: IfNotPresent`),
|
||||
> documented in [04-arc-and-caching.md](./04-arc-and-caching.md). The pinned
|
||||
> `runtimeClassName: kata-qemu` on that same template comes from
|
||||
> [02-kata-runtime.md](./02-kata-runtime.md).
|
||||
|
||||
All host commands below run on `<CI_HOST>` (the single k3s node) as root.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a preloaded image
|
||||
|
||||
The base `ghcr.io/actions/actions-runner:latest` is a clean Ubuntu 24.04 runner.
|
||||
On a normal (GitHub-hosted-style) runner, the CI workflow installs its system
|
||||
dependencies at the start of every job: the cairo/pango native stack for canvas
|
||||
builds, `fd`/`ripgrep`/`imagemagick`, `bun`, `sccache`, Zig, the cargo-native
|
||||
helper CLIs (`cargo-nextest`, `cargo-zigbuild`, `cargo-xwin`), and a pinned Rust
|
||||
nightly with the cross targets/components. Inside a Kata microVM that is
|
||||
destroyed after a single job, paying that apt/bun/rustup/tool-download cost on
|
||||
**every** job is pure latency - the microVM starts cold each time.
|
||||
|
||||
The preloaded image moves that work to build time. Every ephemeral runner then
|
||||
starts with the toolchain already present: apt deps are not re-fetched, `bun`,
|
||||
`cargo`/`rustc`, `sccache`, `zig`, and the cargo helper CLIs are on `PATH`, and
|
||||
the pinned Rust toolchain is already the default so target/component installs in
|
||||
CI become no-ops.
|
||||
|
||||
### Stay in sync with `setup-system-deps`
|
||||
|
||||
The apt set baked into the image **must** match the repo's
|
||||
`.github/actions/setup-system-deps` composite action. That action is the
|
||||
self-healing counterpart: it probes for the baked tools and skips the apt
|
||||
round-trip when they are present (preloaded image), but installs the exact same
|
||||
set on a stock runner so CI still works anywhere. Its detection probes are:
|
||||
|
||||
```bash
|
||||
command -v fd && command -v rg && command -v magick \
|
||||
&& pkg-config --exists cairo pango
|
||||
```
|
||||
|
||||
If you add a dependency that the action probes for, add it in **both** the
|
||||
Dockerfile apt line and `setup-system-deps`. If they drift, either the action
|
||||
re-installs deps the image already has (slow) or CI breaks on a tool the image
|
||||
forgot to bake.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Dockerfile
|
||||
|
||||
Build context lives at `/root/omp-kata-runner-image/`. The Dockerfile below is
|
||||
reproduced verbatim (it contains no secrets or redactable host identifiers; the
|
||||
`ARG` pins, the full apt set, and the toolchain steps are real).
|
||||
|
||||
> The canonical copy of this Dockerfile is version-controlled at
|
||||
> [`infra/runner.Dockerfile`](../runner.Dockerfile);
|
||||
> the `/root/omp-kata-runner-image/` copy is overwritten from it by the
|
||||
> repo-driven reload script (see section 3 below).
|
||||
|
||||
```dockerfile
|
||||
# syntax=docker/dockerfile:1
|
||||
# Preloaded omp-kata runner image.
|
||||
#
|
||||
# Stock GitHub Actions runner (Ubuntu 24.04) with the dependencies CI installs
|
||||
# on every job baked in, so each ephemeral Kata microVM boots with them already
|
||||
# present instead of re-fetching them per job:
|
||||
# - APT system deps (canvas/cairo stack + fd/ripgrep/imagemagick) + fd/magick shims
|
||||
# - GitHub CLI (gh) — present on GitHub-hosted runners; the coding-agent github
|
||||
# tool and release workflows expect it
|
||||
# - C/build toolchain the native + canvas builds need
|
||||
# - bun (system-wide, on PATH)
|
||||
# - sccache + Zig + cargo-nextest/cargo-zigbuild/cargo-xwin for native builds
|
||||
# - rust nightly (pinned) + clippy/rustfmt/rust-analyzer + linux-arm64/windows-msvc targets
|
||||
#
|
||||
# Rebuild + reimport (see /root/omp-kata-runner.md) after bumping the ARGs below
|
||||
# or the apt set. Keep the apt set in sync with .github/actions/setup-system-deps.
|
||||
FROM ghcr.io/actions/actions-runner:latest
|
||||
|
||||
ARG RUST_NIGHTLY=nightly-2026-04-29
|
||||
ARG BUN_VERSION=1.3.14
|
||||
ARG SCCACHE_VERSION=0.15.0
|
||||
ARG ZIG_VERSION=0.16.0
|
||||
|
||||
USER root
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Mirrors the "Install system deps" block in .github/workflows/ci.yml plus the
|
||||
# native/cross toolchain (clang/lld/llvm), the baked cache/tooling binaries, and
|
||||
# the GitHub CLI. The gh apt repo is added first so `gh` installs in the same apt
|
||||
# transaction.
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y \
|
||||
build-essential pkg-config curl ca-certificates git unzip xz-utils gh \
|
||||
clang lld llvm \
|
||||
libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev \
|
||||
fd-find ripgrep imagemagick \
|
||||
&& ln -sf "$(command -v fdfind)" /usr/local/bin/fd \
|
||||
&& ln -sf /usr/bin/convert /usr/local/bin/magick \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# bun, system-wide (BUN_INSTALL/bin == /usr/local/bin, already on PATH).
|
||||
ENV BUN_INSTALL=/usr/local
|
||||
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
|
||||
&& bun --version
|
||||
|
||||
# Pinned native-build helpers, system-wide.
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz -C /tmp \
|
||||
&& install -m755 "/tmp/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache \
|
||||
&& rm -rf "/tmp/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl"
|
||||
RUN curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \
|
||||
&& tar -xJf /tmp/zig.tar.xz -C /opt \
|
||||
&& ln -sf "/opt/zig-x86_64-linux-${ZIG_VERSION}/zig" /usr/local/bin/zig \
|
||||
&& rm -f /tmp/zig.tar.xz
|
||||
|
||||
# rust toolchain + cargo helpers for the runner user; rustup default == pinned
|
||||
# nightly so Rust setup becomes a no-op on the preloaded image.
|
||||
USER runner
|
||||
ENV RUSTUP_HOME=/home/runner/.rustup \
|
||||
CARGO_HOME=/home/runner/.cargo \
|
||||
PATH=/home/runner/.cargo/bin:/usr/local/bin:${PATH}
|
||||
RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain "${RUST_NIGHTLY}" --profile minimal \
|
||||
&& rustup component add clippy rustfmt rust-analyzer \
|
||||
&& rustup target add aarch64-unknown-linux-gnu x86_64-pc-windows-msvc \
|
||||
&& cargo install --locked cargo-nextest cargo-zigbuild cargo-xwin \
|
||||
&& cargo --version \
|
||||
&& rustc --version \
|
||||
&& sccache --version \
|
||||
&& zig version \
|
||||
&& cargo-nextest --version \
|
||||
&& cargo-zigbuild --help >/dev/null \
|
||||
&& cargo-xwin --help >/dev/null
|
||||
```
|
||||
|
||||
### Stage-by-stage annotation
|
||||
|
||||
**`# syntax=docker/dockerfile:1` + `FROM ghcr.io/actions/actions-runner:latest`.**
|
||||
BuildKit frontend pin, then the stock Actions runner base (Ubuntu 24.04). The
|
||||
base already ships the runner agent and its `/home/runner/run.sh` entrypoint, the
|
||||
non-root `runner` user, and `sudo`. Everything below layers onto that base; the
|
||||
runner agent itself is never modified.
|
||||
|
||||
**`ARG RUST_NIGHTLY` / `ARG BUN_VERSION`.** The two version knobs you bump. They
|
||||
are build args so you can also override them ad hoc with
|
||||
`docker build --build-arg RUST_NIGHTLY=... --build-arg BUN_VERSION=...` without
|
||||
editing the file. `RUST_NIGHTLY` must match what the repo's
|
||||
`dtolnay/rust-toolchain@nightly` step expects so the toolchain install in CI is a
|
||||
no-op (see step 4 below).
|
||||
|
||||
**`USER root` + `ENV DEBIAN_FRONTEND=noninteractive`.** Switch to root for the
|
||||
apt and bun system installs; `noninteractive` suppresses debconf/tzdata prompts
|
||||
during `apt-get install`.
|
||||
|
||||
**The apt `RUN` block.** This is the set that must mirror `setup-system-deps`.
|
||||
In order:
|
||||
- The first three lines add the **GitHub CLI apt repository** (keyring + signed
|
||||
source list) *before* `apt-get update`, so `gh` resolves and installs in the
|
||||
same apt transaction as everything else. `gh` is present on GitHub-hosted
|
||||
runners and is expected by the release workflows and the coding-agent `github`
|
||||
tool.
|
||||
- `apt-get install` pulls three groups:
|
||||
- **build toolchain / utilities:** `build-essential pkg-config curl
|
||||
ca-certificates git unzip xz-utils gh clang lld llvm`.
|
||||
`build-essential` + `pkg-config` are needed by the native and canvas builds;
|
||||
`gh` is used by release workflows and the coding-agent GitHub tool; `clang
|
||||
lld llvm` are the MSVC-cross prerequisites that used to be apt-installed per
|
||||
job.
|
||||
- **canvas / cairo native stack:** `libcairo2-dev libpango1.0-dev libjpeg-dev
|
||||
libgif-dev librsvg2-dev` - the `-dev` headers the canvas/rsvg native modules
|
||||
compile against.
|
||||
- **CLI tools:** `fd-find ripgrep imagemagick`, used by the agent and tests.
|
||||
- **The two shims** normalize Debian's binary names to what callers expect:
|
||||
Debian ships `fd` as `fdfind`, so `ln -sf "$(command -v fdfind)"
|
||||
/usr/local/bin/fd` exposes it as `fd`; ImageMagick installs `convert`, so
|
||||
`ln -sf /usr/bin/convert /usr/local/bin/magick` exposes the v7-style `magick`
|
||||
name. These two shims are exactly what `setup-system-deps` recreates on a stock
|
||||
runner.
|
||||
- `rm -rf /var/lib/apt/lists/*` drops the apt index to keep the layer smaller.
|
||||
|
||||
**bun (`ENV BUN_INSTALL=/usr/local` + install `RUN`).** Setting
|
||||
`BUN_INSTALL=/usr/local` makes the official installer drop the binary at
|
||||
`/usr/local/bin/bun`, which is already on `PATH` for every user - so bun is
|
||||
**system-wide** with no per-user shell init. The version is pinned via
|
||||
`bun-v${BUN_VERSION}`, and `bun --version` fails the build if the install is
|
||||
broken.
|
||||
|
||||
**Pinned native-build helpers (two root `RUN`s).** `sccache` is downloaded as a
|
||||
version-pinned GitHub release tarball and installed to `/usr/local/bin`; Zig is
|
||||
downloaded as the pinned release archive, unpacked under `/opt`, and symlinked
|
||||
into `/usr/local/bin/zig`. Baking these two removes the per-job
|
||||
`mozilla-actions/sccache-action` and `mlugg/setup-zig` downloads from the
|
||||
self-hosted path.
|
||||
|
||||
**Rust toolchain (`USER runner` + rustup `RUN`).** The toolchain is installed as
|
||||
the **`runner` user** - the UID jobs execute as - so cargo/rustc are owned by and
|
||||
visible to the job without sudo. `RUSTUP_HOME`/`CARGO_HOME` are pinned under
|
||||
`/home/runner`, and `~/.cargo/bin` is prepended to `PATH`. rustup installs the
|
||||
pinned nightly as the **default toolchain** (`--profile minimal`), then adds the
|
||||
`clippy`, `rustfmt`, and `rust-analyzer` components plus the
|
||||
`aarch64-unknown-linux-gnu` (Linux arm64) and `x86_64-pc-windows-msvc` (Windows
|
||||
cross) targets. The same layer also `cargo install`s the Rust-native helper CLIs
|
||||
`cargo-nextest`, `cargo-zigbuild`, and `cargo-xwin`, so the self-hosted native
|
||||
build path no longer fetches those tools job-by-job. Because the default toolchain
|
||||
already *is* the pinned nightly with these components/targets, the corresponding
|
||||
Rust setup steps in CI become no-ops - the warm-start payoff.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build, import, and roll out (`reload.sh`)
|
||||
|
||||
`/root/omp-kata-runner-image/reload.sh` does the whole cycle: build, in-image
|
||||
smoke test, import into k3s containerd, point ARC at the new tag, and verify the
|
||||
rollout. It is idempotent and cache-fast on an unchanged rebuild. Reproduced
|
||||
verbatim (no secrets; the `/root` and kubeconfig paths are the real host paths):
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild the preloaded omp-kata runner image, import it into k3s containerd,
|
||||
# point the ARC runner scale set at it, and roll it out. Idempotent: safe to
|
||||
# re-run after editing ./Dockerfile. Docker layer cache makes an unchanged
|
||||
# rebuild near-instant.
|
||||
#
|
||||
# ./reload.sh # build tag omp-kata-runner:YYYY-MM-DD-HHMMSS
|
||||
# ./reload.sh 2026-06-20 # build tag omp-kata-runner:2026-06-20
|
||||
# ./reload.sh foo:bar # build an explicit repo:tag
|
||||
set -euo pipefail
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
arg="${1:-$(date +%Y-%m-%d-%H%M%S)}"
|
||||
case "$arg" in *:*) IMAGE="$arg";; *) IMAGE="omp-kata-runner:$arg";; esac
|
||||
|
||||
echo "==> [1/5] building $IMAGE"
|
||||
DOCKER_BUILDKIT=1 docker build -t "$IMAGE" -t omp-kata-runner:preloaded .
|
||||
|
||||
echo "==> [2/5] verifying baked tools"
|
||||
docker run --rm --entrypoint bash "$IMAGE" -lc '
|
||||
set -e
|
||||
for b in gh fd rg magick bun cargo rustc pkg-config clang lld sccache zig cargo-nextest cargo-zigbuild cargo-xwin; do
|
||||
command -v "$b" >/dev/null || { echo "MISSING: $b"; exit 1; }
|
||||
done
|
||||
echo "tools OK | bun $(bun --version) | rust $(rustc --version) | sccache $(sccache --version | awk '\''{print $2}'\'') | zig $(zig version) | gh $(gh --version | head -1 | cut -d\" \" -f3)"
|
||||
'
|
||||
|
||||
echo "==> [3/5] importing into k3s containerd (k8s.io namespace)"
|
||||
docker save "$IMAGE" | k3s ctr -n k8s.io images import --platform linux/amd64 -
|
||||
|
||||
echo "==> [4/5] pointing ARC runner scale set at $IMAGE"
|
||||
sed -i "s#image: omp-kata-runner:.*#image: $IMAGE#" /root/arc-omp-values.yaml
|
||||
helm upgrade omp-kata --namespace arc-runners --version 0.14.2 \
|
||||
-f /root/arc-omp-values.yaml \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set >/dev/null
|
||||
|
||||
echo "==> [5/5] verifying rollout"
|
||||
live="$(kubectl get autoscalingrunnerset omp-kata -n arc-runners -o jsonpath='{.spec.template.spec.containers[0].image}')"
|
||||
echo "ARC runner image is now: $live"
|
||||
[ "$live" = "$IMAGE" ] && echo "OK: reloaded $IMAGE" || { echo "MISMATCH: expected $IMAGE"; exit 1; }
|
||||
```
|
||||
|
||||
Run it with no argument for an auto-dated tag:
|
||||
|
||||
```bash
|
||||
cd /root/omp-kata-runner-image
|
||||
./reload.sh
|
||||
```
|
||||
|
||||
### What each step does
|
||||
|
||||
**Preamble.** `set -euo pipefail` aborts on the first error; `KUBECONFIG` points
|
||||
at the k3s admin config; `cd` into the build context. The tag is resolved from
|
||||
`$1`: no arg gives a timestamped `omp-kata-runner:YYYY-MM-DD-HHMMSS`; an argument
|
||||
containing a colon (`foo:bar`) is used as an explicit `repo:tag`; anything else
|
||||
is treated as a tag suffix on `omp-kata-runner:`.
|
||||
|
||||
**[1/5] build.** `DOCKER_BUILDKIT=1 docker build` tags the result twice: the
|
||||
immutable `$IMAGE` (dated) and the moving `omp-kata-runner:preloaded` alias.
|
||||
BuildKit + the docker layer cache make an unchanged rebuild near-instant.
|
||||
|
||||
**[2/5] verify baked tools.** Runs the freshly built image with a bash entrypoint
|
||||
and asserts every expected binary is on `PATH`
|
||||
(`gh fd rg magick bun cargo rustc pkg-config clang lld sccache zig cargo-nextest cargo-zigbuild cargo-xwin`),
|
||||
failing the whole script if any is missing, then prints the key version tuple
|
||||
(bun / rust / sccache / zig / gh). This catches a broken apt set, missing shim,
|
||||
or bad toolchain pin **before** anything touches the cluster.
|
||||
|
||||
**[3/5] import into k3s containerd.**
|
||||
`docker save "$IMAGE" | k3s ctr -n k8s.io images import --platform linux/amd64 -`
|
||||
streams the image tarball straight from the docker daemon into k3s's **own**
|
||||
containerd, in the `k8s.io` namespace - the namespace the kubelet pulls from.
|
||||
`--platform linux/amd64` matches the host architecture. Note `docker save` is
|
||||
given only the dated `$IMAGE`, so **only the dated tag is imported**; the
|
||||
`:preloaded` alias stays a docker-local convenience and is never imported or
|
||||
referenced by ARC.
|
||||
|
||||
**[4/5] point ARC at the new tag.** `sed -i` rewrites the single
|
||||
`image: omp-kata-runner:...` line in `/root/arc-omp-values.yaml` to the new tag,
|
||||
then `helm upgrade` re-renders the runner scale set with the chart pinned to
|
||||
`0.14.2`. (That values file is the runner pod template, documented in
|
||||
[04-arc-and-caching.md](./04-arc-and-caching.md).)
|
||||
|
||||
**[5/5] verify rollout.** Reads the image back off the live
|
||||
`autoscalingrunnerset` via `kubectl ... jsonpath` and asserts it equals `$IMAGE`,
|
||||
printing `OK` or exiting non-zero with `MISMATCH`. New ephemeral runner pods
|
||||
created after this point boot from the new image; in-flight jobs finish on the
|
||||
old one (the scale set is scale-to-zero, so this drains quickly).
|
||||
|
||||
### Running it from the repo (over SSH)
|
||||
|
||||
You do not have to keep `reload.sh` on the host. The repo ships the version-
|
||||
controlled Dockerfile plus an SSH-driven wrapper that performs the rollout
|
||||
remotely from a checkout:
|
||||
|
||||
- [`infra/runner.Dockerfile`](../runner.Dockerfile) - the image definition (source of truth).
|
||||
- [`infra/reload-runner.sh`](../reload-runner.sh) - copies that Dockerfile to the host, then prefers a **direct containerd build path**: bootstrap pinned `buildkitd` + `buildctl` + `nerdctl` under the remote build dir if needed, build straight into the k3s `k8s.io` namespace, smoke-test from that image store, then `helm upgrade` ARC. Set `BUILD_BACKEND=docker` to force the legacy `docker build` + `docker save | ctr images import` path.
|
||||
|
||||
The host is never hardcoded; point it at your node with `CI_HOST`:
|
||||
|
||||
```bash
|
||||
CI_HOST=<CI_HOST> ./infra/reload-runner.sh # dated tag
|
||||
CI_HOST=<CI_HOST> ./infra/reload-runner.sh 2026-06-20 # explicit tag
|
||||
```
|
||||
|
||||
It honors the same defaults as the host script (remote build dir, ARC values
|
||||
path, release name, namespace, chart version), each overridable via the
|
||||
environment variables documented in the script header.
|
||||
|
||||
---
|
||||
|
||||
## 4. Why import into k3s containerd instead of using a registry
|
||||
|
||||
This is a single-node cluster, and the **only** consumer of the runner image is
|
||||
the kubelet/containerd on that same node. A registry would add a service to run,
|
||||
secure, and authenticate against, for zero benefit. Instead:
|
||||
|
||||
- `docker save | k3s ctr -n k8s.io images import` places the image directly into
|
||||
the containerd instance k3s schedules from. containerd normalizes the short
|
||||
reference `omp-kata-runner:<tag>` to `docker.io/library/omp-kata-runner:<tag>`
|
||||
in its store (verified: the imported tags appear under that prefix).
|
||||
- The ARC pod template sets `imagePullPolicy: IfNotPresent`. Because the image is
|
||||
already present locally, the kubelet **uses the local copy and never attempts a
|
||||
pull** - no registry, no pull credentials, no registry egress (which the
|
||||
runner egress lockdown in [04-arc-and-caching.md](./04-arc-and-caching.md)
|
||||
would block anyway).
|
||||
|
||||
Trade-off: the image must be (re-)imported on every node that schedules runners.
|
||||
Here that is exactly one node, so a re-roll is simply rebuild + re-import, and the
|
||||
next job's microVM starts cold but with warm dependencies from the local store.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tag conventions
|
||||
|
||||
| Tag | Mutability | Imported into containerd? | Referenced by ARC? | Purpose |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `omp-kata-runner:YYYY-MM-DD-HHMMSS` | immutable | yes | yes | the build of record; what runners actually boot |
|
||||
| `omp-kata-runner:preloaded` | moving | no | no | docker-local alias to the most recent build |
|
||||
|
||||
- The default `reload.sh` tag is timestamped (`date +%Y-%m-%d-%H%M%S`). You can
|
||||
also pass a date-only tag (`./reload.sh 2026-06-20`) or an explicit `repo:tag`.
|
||||
- ARC always pins the **immutable dated tag**, never `:preloaded`. That keeps a
|
||||
rollout reproducible and makes rollback trivial: `sed` the image line back to
|
||||
the prior dated tag (it is still in the local store) and `helm upgrade`.
|
||||
- Live example at time of writing: the scale set references
|
||||
`omp-kata-runner:2026-06-15-002621`, held in containerd as
|
||||
`docker.io/library/omp-kata-runner:2026-06-15-002621`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bumping bun / Rust / the apt set and re-rolling
|
||||
|
||||
1. **bun:** edit `ARG BUN_VERSION=` in the Dockerfile (or pass
|
||||
`--build-arg BUN_VERSION=...`).
|
||||
2. **Rust:** edit `ARG RUST_NIGHTLY=` to the new pinned nightly. Keep it equal to
|
||||
what the repo's `dtolnay/rust-toolchain@nightly` step resolves, so the CI
|
||||
toolchain install stays a no-op.
|
||||
3. **apt set:** edit the `apt-get install` line. You **must** mirror the change in
|
||||
`.github/actions/setup-system-deps` (and, if you add a tool the action probes
|
||||
for, in its detection block - currently `fd`, `rg`, `magick`,
|
||||
`pkg-config --exists cairo pango`).
|
||||
4. Re-roll:
|
||||
|
||||
```bash
|
||||
cd /root/omp-kata-runner-image
|
||||
./reload.sh
|
||||
```
|
||||
|
||||
The rebuild is cache-fast for unchanged layers, the baked-tools check guards
|
||||
the change, and the import/helm-upgrade/verify steps roll the new tag out.
|
||||
The next job's microVM boots warm with the updated toolchain.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification
|
||||
|
||||
### Baked-tools check (any tag)
|
||||
|
||||
`reload.sh` step 2 already runs this on every build. To re-check an existing tag
|
||||
standalone:
|
||||
|
||||
```bash
|
||||
docker run --rm --entrypoint bash omp-kata-runner:preloaded -lc '
|
||||
set -e
|
||||
for b in gh fd rg magick bun cargo rustc pkg-config clang lld sccache zig cargo-nextest cargo-zigbuild cargo-xwin; do
|
||||
command -v "$b" >/dev/null || { echo "MISSING: $b"; exit 1; }
|
||||
done
|
||||
echo "tools OK | bun $(bun --version) | rust $(rustc --version) | sccache $(set -- $(sccache --version); echo "$2") | zig $(zig version) | gh $(set -- $(gh --version | head -1); echo "$3")"
|
||||
'
|
||||
```
|
||||
|
||||
Confirm the live ARC reference and that the tag exists in the k3s image store
|
||||
(both read-only):
|
||||
|
||||
```bash
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
kubectl get autoscalingrunnerset omp-kata -n arc-runners \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
|
||||
k3s ctr -n k8s.io images ls | grep omp-kata-runner
|
||||
```
|
||||
|
||||
### Kata microVM boot check
|
||||
|
||||
The baked-tools check above runs the image under plain docker; it does **not**
|
||||
prove the image boots inside a Kata QEMU/KVM microVM. To verify that, launch a
|
||||
throwaway pod with `runtimeClassName: kata-qemu` (the RuntimeClass set up in
|
||||
[02-kata-runtime.md](./02-kata-runtime.md)) and confirm both the deps and the
|
||||
**guest** kernel:
|
||||
|
||||
```bash
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
kubectl run preload-verify -n arc-runners --restart=Never \
|
||||
--image=omp-kata-runner:2026-06-15-002621 \
|
||||
--overrides='{"spec":{"runtimeClassName":"kata-qemu"}}' \
|
||||
--command -- bash -lc 'uname -r; bun --version; rustc --version; magick -version | head -1'
|
||||
|
||||
kubectl logs preload-verify -n arc-runners
|
||||
kubectl delete pod preload-verify -n arc-runners
|
||||
```
|
||||
|
||||
Use the tag that is currently live (or any imported tag). `uname -r` should show
|
||||
the Kata **guest** kernel (a 6.x `vmlinux.container` build), not the host kernel -
|
||||
confirming the image really booted in its own microVM - and the `bun`/`rustc`/
|
||||
`magick` lines confirm the baked toolchain is present inside the VM. This `kubectl
|
||||
run` is the only step here that creates a cluster object; delete the pod
|
||||
afterward as shown.
|
||||
|
||||
---
|
||||
|
||||
Continue to [04-arc-and-caching.md](./04-arc-and-caching.md) for how ARC
|
||||
wires in the shared `sccache`/Bun/Cargo cache storage, and locks down runner
|
||||
egress.
|
||||
@@ -0,0 +1,768 @@
|
||||
# 04 - ARC runners, shared caches, and egress policy
|
||||
|
||||
This is the last setup step. By now the node runs k3s with the `kata-qemu`
|
||||
RuntimeClass ([02-kata-runtime.md](02-kata-runtime.md)) and the preloaded runner
|
||||
image has been imported into the cluster containerd ([03-runner-image.md](03-runner-image.md)).
|
||||
Here we install **actions-runner-controller (ARC)**, register an ephemeral
|
||||
**scale set** whose pods each boot inside their own Kata microVM, stand up the
|
||||
in-cluster **RustFS (S3)** `sccache` backend and the runner cache PVC, and lock
|
||||
down runner egress with a NetworkPolicy. See [README.md](README.md) for the
|
||||
architecture overview.
|
||||
|
||||
Everything below is read against the live cluster; set the kubeconfig once:
|
||||
|
||||
```bash
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
```
|
||||
|
||||
ARC's `gha-runner-scale-set` flavour has three moving parts:
|
||||
|
||||
- **Controller** (`arc` release, ns `arc-systems`) - watches `AutoscalingRunnerSet`
|
||||
custom resources and reconciles them.
|
||||
- **Listener** (one pod per scale set, ns `arc-systems`) - long-polls the GitHub
|
||||
Actions service for jobs targeting the scale set's `runs-on` label.
|
||||
- **Scale set** (`omp-kata` release, ns `arc-runners`) - the `AutoscalingRunnerSet`
|
||||
plus the pod template; the controller turns assigned jobs into ephemeral runner
|
||||
pods here.
|
||||
|
||||
---
|
||||
|
||||
## 1. GitHub App and the `arc-github` secret
|
||||
|
||||
The listener authenticates to GitHub. The durable option is a **GitHub App**
|
||||
(no expiring user token, scoped to exactly the repos you install it on).
|
||||
|
||||
1. Create the App at **GitHub - Settings - Developer settings - GitHub Apps - New GitHub App**.
|
||||
- **Repository permissions**: `Administration: Read and write` (register/remove
|
||||
self-hosted runners) and `Metadata: Read-only` (granted automatically).
|
||||
- No webhook is needed for the scale-set flavour; uncheck **Active** under Webhook.
|
||||
- Generate and download a **private key** (`.pem`).
|
||||
2. **Install** the App on the target repo or org (App page - **Install App** -
|
||||
pick `<OWNER>/<REPO>` or "All repositories"). Note the **App ID** and the
|
||||
**Installation ID** (the trailing number in the install settings URL,
|
||||
`.../installations/<id>`).
|
||||
3. Create the secret in the runners namespace. The three key names below are
|
||||
exactly what the chart reads:
|
||||
|
||||
```bash
|
||||
kubectl create namespace arc-runners
|
||||
|
||||
kubectl -n arc-runners create secret generic arc-github \
|
||||
--from-literal=github_app_id=<GITHUB_APP_ID> \
|
||||
--from-literal=github_app_installation_id=<GITHUB_APP_INSTALLATION_ID> \
|
||||
--from-literal=github_app_private_key=<GITHUB_APP_PRIVATE_KEY>
|
||||
```
|
||||
|
||||
`<GITHUB_APP_PRIVATE_KEY>` is the full PEM body (use `--from-file=github_app_private_key=key.pem`
|
||||
to avoid shell-quoting the multi-line value).
|
||||
|
||||
Verify the live secret carries those three keys (names only - never print values):
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners get secret arc-github \
|
||||
-o go-template='{{range $k,$v := .data}}{{$k}}{{"\n"}}{{end}}'
|
||||
# github_app_id
|
||||
# github_app_installation_id
|
||||
# github_app_private_key
|
||||
```
|
||||
|
||||
**Token alternative.** ARC also accepts a single-key secret with a classic PAT
|
||||
(scope `repo`) or a fine-grained PAT (`Administration: RW` + `Metadata: R`):
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners create secret generic arc-github \
|
||||
--from-literal=github_token=<GITHUB_PAT>
|
||||
```
|
||||
|
||||
The App is preferred: it does not expire, it is scoped per-installation, and one
|
||||
installation covers every repo you grant it (useful for [adding another repo](#7-operate)).
|
||||
Whichever you choose, the `githubConfigSecret` value in step 3's chart points at
|
||||
this secret by name.
|
||||
|
||||
---
|
||||
|
||||
## 2. Install ARC (controller + scale set)
|
||||
|
||||
ARC ships as OCI Helm charts; no `helm repo add` is required. Both the controller
|
||||
and the scale set are pinned to the same chart version, **0.14.2** (matches the
|
||||
live `helm list -A`).
|
||||
|
||||
**Controller** (installed with chart defaults - `helm get values arc` is empty):
|
||||
|
||||
```bash
|
||||
helm install arc \
|
||||
--namespace arc-systems --create-namespace \
|
||||
--version 0.14.2 \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
|
||||
```
|
||||
|
||||
**Scale set** (`omp-kata`), using the runner cache PVC and values file from step 3:
|
||||
```bash
|
||||
helm install omp-kata \
|
||||
--namespace arc-runners --create-namespace \
|
||||
--version 0.14.2 \
|
||||
-f arc-omp-values.yaml \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
|
||||
```
|
||||
|
||||
Confirm both releases and the running controller image:
|
||||
|
||||
```bash
|
||||
helm list -A
|
||||
# arc arc-systems deployed gha-runner-scale-set-controller-0.14.2 0.14.2
|
||||
# omp-kata arc-runners deployed gha-runner-scale-set-0.14.2 0.14.2
|
||||
|
||||
kubectl -n arc-systems get deploy arc-gha-rs-controller \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
|
||||
# ghcr.io/actions/gha-runner-scale-set-controller:0.14.2
|
||||
```
|
||||
|
||||
Within a few seconds the controller spawns the listener in `arc-systems`:
|
||||
|
||||
```bash
|
||||
kubectl -n arc-systems get pods
|
||||
# arc-gha-rs-controller-xxxxxxxxxx-xxxxx 1/1 Running
|
||||
# omp-kata-<hash>-listener 1/1 Running
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Scale-set values (`arc-omp-values.yaml`)
|
||||
|
||||
Create the namespace-local PVC before installing or upgrading the scale set. This
|
||||
is the shared mutable filesystem cache for data whose tools already validate
|
||||
against the lockfile: Bun's global package store and Cargo's registry cache.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: runner-cache
|
||||
namespace: arc-runners
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Gi
|
||||
```
|
||||
|
||||
Apply it once:
|
||||
|
||||
```bash
|
||||
kubectl apply -f runner-cache-pvc.yaml
|
||||
```
|
||||
|
||||
This is the live `arc-omp-values.yaml` verbatim, with only the repo owner/name in
|
||||
`githubConfigUrl` redacted:
|
||||
|
||||
```yaml
|
||||
githubConfigUrl: "https://github.com/<OWNER>/<REPO>"
|
||||
githubConfigSecret: arc-github
|
||||
runnerScaleSetName: omp-kata
|
||||
minRunners: 0
|
||||
maxRunners: 10
|
||||
# none: each job runs inside the runner container, which itself lives in a Kata microVM
|
||||
containerMode:
|
||||
type: ""
|
||||
template:
|
||||
spec:
|
||||
runtimeClassName: kata-qemu # <-- every runner pod boots its own KVM microVM
|
||||
securityContext:
|
||||
# ghcr.io/actions/actions-runner runs jobs as uid/gid 1001 ("runner").
|
||||
# Let kubelet make the PVC writable by that user without changing image-owned
|
||||
# ~/.cargo/bin or ~/.rustup.
|
||||
fsGroup: 1001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
initContainers:
|
||||
- name: prepare-runner-cache
|
||||
image: omp-kata-runner:2026-06-15-002621
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- bash
|
||||
- -lc
|
||||
- install -d -o 1001 -g 1001 -m 2775 /cache/bun-store /cache/cargo-registry
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
volumeMounts:
|
||||
- name: runner-cache
|
||||
mountPath: /cache
|
||||
containers:
|
||||
- name: runner
|
||||
# Preloaded image: stock ghcr.io/actions/actions-runner + CI deps baked in
|
||||
# (apt cairo/pango/jpeg/gif/rsvg stack, fd/ripgrep/imagemagick, bun, rust
|
||||
# nightly + clippy/rustfmt + arm64/msvc targets). Built + imported locally;
|
||||
# see /root/omp-kata-runner-image/. IfNotPresent uses the local image.
|
||||
image: omp-kata-runner:2026-06-15-002621
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/home/runner/run.sh"]
|
||||
# Shared sccache backend (in-cluster RustFS S3). Exposes SCCACHE_BUCKET/
|
||||
# ENDPOINT/REGION/USE_SSL + AWS creds to every job; CI flips RUSTC_WRAPPER
|
||||
# on for rust builds only. GitHub-hosted runners lack this env and keep the
|
||||
# GHA cache backend. See /root/sccache-rustfs/.
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: sccache-s3
|
||||
volumeMounts:
|
||||
# Shared stores only. Keep node_modules, Cargo target/, and Cargo git
|
||||
# checkouts per-job to avoid mutable build-output or checkout poisoning.
|
||||
- name: runner-cache
|
||||
mountPath: /home/runner/.bun/install/cache
|
||||
subPath: bun-store
|
||||
- name: runner-cache
|
||||
mountPath: /home/runner/.cargo/registry
|
||||
subPath: cargo-registry
|
||||
resources:
|
||||
requests:
|
||||
cpu: "2"
|
||||
memory: "4Gi"
|
||||
limits:
|
||||
cpu: "16"
|
||||
memory: "12Gi"
|
||||
volumes:
|
||||
- name: runner-cache
|
||||
persistentVolumeClaim:
|
||||
claimName: runner-cache
|
||||
```
|
||||
|
||||
Field by field:
|
||||
|
||||
- **`githubConfigUrl`** - the repo (or org) the scale set serves. Jobs reach it
|
||||
with `runs-on: omp-kata`.
|
||||
- **`githubConfigSecret: arc-github`** - the auth secret from [step 1](#1-github-app-and-the-arc-github-secret).
|
||||
- **`runnerScaleSetName: omp-kata`** - the runner label. This is the string that
|
||||
goes in a workflow's `runs-on:`.
|
||||
- **`minRunners: 0` / `maxRunners: 10`** - **scale-to-zero**. With no queued jobs
|
||||
there are zero runner pods (and zero microVMs) consuming the node; the listener
|
||||
scales up to ten concurrent runners on demand. (The older ops notes capped this
|
||||
at 3; the live value is 10.)
|
||||
- **`containerMode.type: ""`** - **none**. The default chart offers `dind`
|
||||
(Docker-in-Docker sidecar) or `kubernetes` mode for job-container isolation;
|
||||
both are unnecessary here because the *whole runner pod* is already isolated in
|
||||
a microVM. The job runs directly in the runner container - no privileged dind
|
||||
sidecar, no extra attack surface.
|
||||
- **`template.spec.runtimeClassName: kata-qemu`** - the critical line. It binds
|
||||
the pod to the Kata QEMU runtime ([02-kata-runtime.md](02-kata-runtime.md)), so
|
||||
every runner boots its own KVM microVM with a guest kernel distinct from the host.
|
||||
- **`image` / `imagePullPolicy: IfNotPresent`** - the locally built, dependency-baked
|
||||
runner image ([03-runner-image.md](03-runner-image.md)). `IfNotPresent` uses the
|
||||
copy already imported into cluster containerd; there is no registry. Bump the tag
|
||||
here when you rebuild the image (see [Operate](#7-operate)).
|
||||
- **`command: ["/home/runner/run.sh"]`** - the stock actions-runner entrypoint;
|
||||
overridden explicitly because the custom image keeps the upstream layout.
|
||||
- **`envFrom.secretRef.name: sccache-s3`** - injects only the S3 configuration that
|
||||
`sccache` needs ([step 5](#5-shared-caches-rustfs-s3--runner-pvc)). Bun and
|
||||
Cargo no longer use RustFS.
|
||||
- **`securityContext.fsGroup: 1001`** - makes the mounted PVC writable by the
|
||||
image's `runner` user without replacing image-owned `~/.cargo/bin` or `~/.rustup`.
|
||||
- **`initContainers.prepare-runner-cache`** - uses the same locally imported image
|
||||
to create the PVC subdirectories as root before the runner starts. This avoids
|
||||
relying on kubelet's subPath auto-create permissions and does not pull another
|
||||
image.
|
||||
- **`volumeMounts`** - mounts the shared PVC only at `~/.bun/install/cache` and
|
||||
`~/.cargo/registry`. `node_modules`, Cargo `target/`, and Cargo git checkouts
|
||||
stay inside the throwaway VM filesystem.
|
||||
- **`volumes[].persistentVolumeClaim.claimName: runner-cache`** - binds those
|
||||
mounts to the `arc-runners/runner-cache` PVC. `ReadWriteOnce` is enough on this
|
||||
single-node k3s host; use a RWX-capable storage class before spreading runners
|
||||
across nodes.
|
||||
- **`resources`** - requests `2` CPU / `4Gi`, limits `16` CPU / `12Gi`. Kata reads
|
||||
these and sizes the guest accordingly: the VM now boots at the same
|
||||
guaranteed floor (`default_vcpus: 2`, `default_memory: 4096`) and only
|
||||
hotplugs beyond that toward the limits, with `default_maxvcpus: 0` allowing up
|
||||
to all host CPUs. Effectively the **requests are the boot-time VM size** and
|
||||
the **limits are the hotplug ceiling**. See [02-kata-runtime.md](02-kata-runtime.md)
|
||||
for the runtime knobs and [`infra/tune-kata-runtime.sh`](../tune-kata-runtime.sh)
|
||||
for the SSH-driven patch helper.
|
||||
|
||||
---
|
||||
|
||||
## 4. Job lifecycle and the no-permission ServiceAccount
|
||||
|
||||
One job runs in one fresh microVM that is destroyed afterward:
|
||||
|
||||
1. The **listener** (ns `arc-systems`) long-polls the GitHub Actions service for
|
||||
jobs whose `runs-on` matches `omp-kata`.
|
||||
2. When jobs are assigned, the controller reconciles the `AutoscalingRunnerSet`
|
||||
and creates an **`EphemeralRunnerSet`** sized to the demand (bounded by
|
||||
`minRunners`/`maxRunners`).
|
||||
3. Each replica becomes an **ephemeral runner pod** registered **just-in-time
|
||||
(JIT)** with GitHub - a per-runner registration secret is minted, not a
|
||||
long-lived token.
|
||||
4. Because the pod's `runtimeClassName` is `kata-qemu`, it **boots a microVM**,
|
||||
pulls the one assigned job, runs it, and exits.
|
||||
5. ARC **deletes the pod** (and its microVM); a clean VM is created for the next
|
||||
job. There is no VM templating - state never leaks between jobs.
|
||||
|
||||
Observe the chain live:
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners get autoscalingrunnerset omp-kata
|
||||
kubectl -n arc-runners get ephemeralrunnerset
|
||||
kubectl -n arc-runners get pods -o wide # one pod per in-flight job; empty when idle
|
||||
```
|
||||
|
||||
**No-permission ServiceAccount.** The scale-set chart runs every runner pod under
|
||||
a ServiceAccount with no RBAC bindings:
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners get sa
|
||||
# default
|
||||
# omp-kata-gha-rs-no-permission
|
||||
```
|
||||
|
||||
Job code therefore has no Kubernetes API rights - it cannot read secrets, list
|
||||
pods, or touch the cluster, even though it executes inside the cluster. Combined
|
||||
with microVM isolation and the egress policy ([step 6](#6-runner-egress-lockdown)),
|
||||
a compromised job is boxed into a throwaway VM with no cluster reach.
|
||||
|
||||
---
|
||||
|
||||
## 5. Shared caches (RustFS S3 + runner PVC)
|
||||
|
||||
GitHub's hosted cache backend is only reachable over the node's NAT egress, so on
|
||||
a busy matrix (many concurrent jobs) it becomes the bottleneck. This setup keeps
|
||||
the hot paths inside the cluster:
|
||||
|
||||
- **RustFS S3** backs `sccache` for Rust compiler outputs.
|
||||
- **`runner-cache` PVC** is mounted into every runner for Bun's global package
|
||||
store and Cargo's crates.io registry cache.
|
||||
|
||||
### 5a. Deploy RustFS
|
||||
|
||||
The store lives in its own `sccache` namespace: a `local-path` PVC for durability,
|
||||
a single-replica Deployment, and a ClusterIP Service. This is `rustfs.yaml`
|
||||
verbatim (no secrets inline - credentials come from a separate secret):
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: sccache
|
||||
labels:
|
||||
kubernetes.io/metadata.name: sccache
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: rustfs-data
|
||||
namespace: sccache
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: rustfs
|
||||
namespace: sccache
|
||||
labels: { app: rustfs }
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: { type: Recreate }
|
||||
selector:
|
||||
matchLabels: { app: rustfs }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: rustfs }
|
||||
spec:
|
||||
containers:
|
||||
- name: rustfs
|
||||
image: rustfs/rustfs:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: RUSTFS_ACCESS_KEY
|
||||
valueFrom: { secretKeyRef: { name: rustfs-creds, key: RUSTFS_ACCESS_KEY } }
|
||||
- name: RUSTFS_SECRET_KEY
|
||||
valueFrom: { secretKeyRef: { name: rustfs-creds, key: RUSTFS_SECRET_KEY } }
|
||||
- name: RUSTFS_VOLUMES
|
||||
value: "/data"
|
||||
- name: RUSTFS_ADDRESS
|
||||
value: ":9000"
|
||||
- name: RUSTFS_CONSOLE_ENABLE
|
||||
value: "false"
|
||||
- name: RUSTFS_OBS_LOG_DIRECTORY
|
||||
value: "/logs"
|
||||
ports:
|
||||
- { name: s3, containerPort: 9000 }
|
||||
volumeMounts:
|
||||
- { name: data, mountPath: /data }
|
||||
- { name: logs, mountPath: /logs }
|
||||
readinessProbe:
|
||||
tcpSocket: { port: 9000 }
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
tcpSocket: { port: 9000 }
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests: { cpu: "200m", memory: "256Mi" }
|
||||
limits: { cpu: "2", memory: "2Gi" }
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim: { claimName: rustfs-data }
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: rustfs
|
||||
namespace: sccache
|
||||
spec:
|
||||
selector: { app: rustfs }
|
||||
ports:
|
||||
- { name: s3, port: 9000, targetPort: 9000, protocol: TCP }
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **`strategy: Recreate`** with a single replica and an RWO `local-path` PVC: the
|
||||
data is node-local and only one pod ever mounts it.
|
||||
- **`RUSTFS_CONSOLE_ENABLE: "false"`** - only the S3 API on `:9000` is exposed;
|
||||
no admin console.
|
||||
- The `kubernetes.io/metadata.name: sccache` namespace label is what the egress
|
||||
NetworkPolicy's `namespaceSelector` matches ([step 6](#6-runner-egress-lockdown)).
|
||||
|
||||
The RustFS pod credentials come from a two-key secret in the `sccache` namespace
|
||||
(values are the object-store root credentials - use placeholders):
|
||||
|
||||
```bash
|
||||
kubectl -n sccache create secret generic rustfs-creds \
|
||||
--from-literal=RUSTFS_ACCESS_KEY=<S3_ACCESS_KEY> \
|
||||
--from-literal=RUSTFS_SECRET_KEY=<S3_SECRET_KEY>
|
||||
```
|
||||
|
||||
Apply and verify:
|
||||
|
||||
```bash
|
||||
kubectl apply -f rustfs.yaml
|
||||
kubectl -n sccache get deploy,svc,pvc
|
||||
# deployment.apps/rustfs 1/1
|
||||
# service/rustfs ClusterIP 10.43.x.x 9000/TCP
|
||||
# persistentvolumeclaim/rustfs-data Bound 100Gi local-path
|
||||
```
|
||||
|
||||
Create the `sccache` bucket once (any S3 client - e.g. the `aws` CLI or `mc`
|
||||
pointed at the endpoint with the root creds): `mb s3://sccache`.
|
||||
|
||||
### 5b. The `sccache-s3` secret (injected into every runner)
|
||||
|
||||
Every runner pod gets the `sccache` S3 configuration via `envFrom` ([step 3](#3-scale-set-values-arc-omp-valuesyaml)).
|
||||
The secret lives in `arc-runners` (the runners' namespace) and has six keys:
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners get secret sccache-s3 \
|
||||
-o go-template='{{range $k,$v := .data}}{{$k}}{{"\n"}}{{end}}'
|
||||
# AWS_ACCESS_KEY_ID
|
||||
# AWS_SECRET_ACCESS_KEY
|
||||
# SCCACHE_BUCKET
|
||||
# SCCACHE_ENDPOINT
|
||||
# SCCACHE_REGION
|
||||
# SCCACHE_S3_USE_SSL
|
||||
```
|
||||
|
||||
Recreate it (the two credential values must equal the `rustfs-creds` above; the
|
||||
rest are non-sensitive cluster-local config):
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners create secret generic sccache-s3 \
|
||||
--from-literal=AWS_ACCESS_KEY_ID=<S3_ACCESS_KEY> \
|
||||
--from-literal=AWS_SECRET_ACCESS_KEY=<S3_SECRET_KEY> \
|
||||
--from-literal=SCCACHE_BUCKET=sccache \
|
||||
--from-literal=SCCACHE_ENDPOINT=rustfs.sccache.svc.cluster.local:9000 \
|
||||
--from-literal=SCCACHE_REGION=us-east-1 \
|
||||
--from-literal=SCCACHE_S3_USE_SSL=false
|
||||
```
|
||||
|
||||
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` - the **only sensitive entries**;
|
||||
RustFS's root credentials (S3 SigV4 auth).
|
||||
- `SCCACHE_BUCKET: sccache` - bucket name.
|
||||
- `SCCACHE_ENDPOINT` - the in-cluster Service DNS + port.
|
||||
- `SCCACHE_REGION: us-east-1` - arbitrary region label SigV4 requires.
|
||||
- `SCCACHE_S3_USE_SSL: false` - the endpoint is plain HTTP on the cluster network.
|
||||
|
||||
### 5c. The cache consumers
|
||||
|
||||
The presence of `$SCCACHE_BUCKET` in the environment is the repo's single signal
|
||||
for "am I on the self-hosted infra?". Cache behavior branches on it and falls
|
||||
back to GitHub-hosted cache backends off-infra. Off-infra covers GitHub-hosted
|
||||
macOS/arm runners (which never get the secret and cannot reach the private
|
||||
RustFS) and **every pull request**: `ci.yml` pins PR jobs to GitHub-hosted
|
||||
`ubuntu-22.04`, so omp-kata only runs trusted `push`/main + release builds (see
|
||||
[5d](#5d-poisoning-boundary-and-pressure)).
|
||||
|
||||
**(a) sccache for Rust compiler outputs** -
|
||||
[`.github/actions/build-native`](../../.github/actions/build-native/action.yml).
|
||||
One action serves both environments: a "Detect runner environment" step reads
|
||||
`$SCCACHE_BUCKET` and branches each toolchain/cache step on it. It sets
|
||||
`RUSTC_WRAPPER=sccache` and `CARGO_INCREMENTAL=0` (sccache silently no-ops with
|
||||
incremental enabled). The sccache backend is conditional:
|
||||
|
||||
- `$SCCACHE_BUCKET` set (omp-kata) - sccache reads `SCCACHE_BUCKET/ENDPOINT/REGION`
|
||||
and the AWS creds straight from the inherited pod env and uses the **shared S3
|
||||
(RustFS)**; toolchains come from the baked image via the `ensure-*` actions.
|
||||
- otherwise (GitHub-hosted) - it installs the toolchains, exports
|
||||
`SCCACHE_GHA_ENABLED=true`, and uses the **GitHub Actions cache**.
|
||||
|
||||
`Swatinem/rust-cache` runs only on GitHub-hosted runners (it caches Cargo
|
||||
`target/`). On omp-kata the mounted Cargo registry handles crate downloads and
|
||||
sccache fills the compile-output gap when `target/` is cold.
|
||||
|
||||
**(b) Cargo registry cache** - the scale-set pod template mounts
|
||||
`runner-cache:/cargo-registry` at `/home/runner/.cargo/registry`. Cargo uses it
|
||||
automatically because the image keeps `CARGO_HOME=/home/runner/.cargo`.
|
||||
|
||||
Only the registry cache is shared. Cargo `target/` stays per-job, and
|
||||
`/home/runner/.cargo/git` stays per-job too; this repo has no git dependencies,
|
||||
and git checkouts are a worse shared mutable-cache boundary than crates.io
|
||||
archives with lockfile checksums.
|
||||
|
||||
**(c) Bun package store** -
|
||||
[`.github/actions/bun-install`](../../.github/actions/bun-install/action.yml)
|
||||
wraps `bun install --frozen-lockfile`. On omp-kata, the pod template mounts
|
||||
`runner-cache:/bun-store` at Bun's default store path
|
||||
(`/home/runner/.bun/install/cache`), so the action only ensures the directory
|
||||
exists before running Bun. Off-infra it still uses stock `actions/cache@v4` for
|
||||
the same store path.
|
||||
|
||||
`node_modules` is deliberately not shared. It is lockfile-, platform-, script-,
|
||||
and workspace-state-sensitive, and concurrent jobs would write through the same
|
||||
tree. The clean VM still runs `bun install --frozen-lockfile`; it just reuses the
|
||||
package tarball/extract store.
|
||||
|
||||
### 5d. Poisoning boundary and pressure
|
||||
|
||||
The shared writable PVC and the sccache S3 bucket are both poisonable by any job
|
||||
that runs on `omp-kata`, and a poisoned entry could be consumed by a later
|
||||
trusted build (a supply-chain risk). The primary defense is to **keep untrusted
|
||||
code off the self-hosted runner entirely**:
|
||||
|
||||
- `ci.yml` routes every pull-request job to GitHub-hosted `ubuntu-22.04`
|
||||
(`runs-on` resolves to `omp-kata` only for `push`/main, manual dispatch, and
|
||||
release). That expression lives in the base workflow, which GitHub uses
|
||||
verbatim for `pull_request` events, so a fork cannot override it. Fork/PR code
|
||||
therefore never sees the PVC, the `sccache-s3` creds, or RustFS - it runs
|
||||
sandboxed on GitHub-hosted runners with the off-infra cache backends.
|
||||
- As defense in depth, set the repo's **Settings -> Actions -> Fork pull request
|
||||
workflows** policy to *Require approval for all outside collaborators* (or all
|
||||
forks). GitHub's public-repo default only gates first-time contributors, which
|
||||
would otherwise let a returning contributor's workflow start without review.
|
||||
|
||||
omp-kata thus only ever serves trusted `push`/main + release builds. The
|
||||
mounted-cache design also narrows the blast radius of those trusted runs:
|
||||
|
||||
- no shared `node_modules`;
|
||||
- no shared Cargo `target/`;
|
||||
- no shared Cargo git checkouts;
|
||||
- Bun still installs from `bun.lock`;
|
||||
- Cargo registry entries are checked against Cargo's lockfile/source checksums;
|
||||
- Rust compiler outputs stay in sccache's content-addressed backend.
|
||||
|
||||
Pressure now has two places to watch:
|
||||
|
||||
- `sccache/rustfs-data` for Rust compiler objects;
|
||||
- `arc-runners/runner-cache` for Bun store + Cargo registry files.
|
||||
|
||||
For the runner PVC, the safe cleanup is simple and coarse: scale `omp-kata` to
|
||||
zero, delete either `bun-store/` or `cargo-registry/` from the bound local-path
|
||||
volume, then let the next jobs repopulate it. There are no RustFS Bun objects and
|
||||
no `node_modules` archives to prune anymore.
|
||||
|
||||
RustFS remains inside the egress allow-list on `tcp/9000` because sccache still
|
||||
uses it ([step 6](#6-runner-egress-lockdown)).
|
||||
|
||||
---
|
||||
|
||||
## 6. Runner egress lockdown
|
||||
|
||||
Runner pods reach the public internet (GitHub, package registries, crates.io,
|
||||
npm) but must **not** reach the host's own services, the LAN, the tailnet, or
|
||||
arbitrary cluster workloads. A single NetworkPolicy in `arc-runners` enforces
|
||||
this. Because the pod template sets no special labels, the policy uses
|
||||
`podSelector: {}` to cover **every** pod in the namespace.
|
||||
|
||||
> k3s ships a built-in NetworkPolicy controller (kube-router based) that enforces
|
||||
> policies even though the CNI is Flannel - so this policy actually takes effect.
|
||||
> Do not start k3s with `--disable-network-policy` ([01-host-and-cluster.md](01-host-and-cluster.md)),
|
||||
> or the lockdown silently becomes a no-op.
|
||||
|
||||
Live spec (captured with `kubectl get networkpolicy -n arc-runners runner-egress-lockdown -o yaml`;
|
||||
server-managed metadata omitted, host public IP redacted):
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: runner-egress-lockdown
|
||||
namespace: arc-runners
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
egress:
|
||||
# 1. Cluster DNS only (CoreDNS + kube-system).
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.43.0.10/32
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# 2. Public internet, MINUS all private/infra ranges and the host's own public IP.
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.0.0.0/8
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
- 169.254.0.0/16
|
||||
- 100.64.0.0/10
|
||||
- <PUBLIC_IP>/32
|
||||
# 3. RustFS shared cache (S3) over the cluster network.
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.43.0.0/16
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: sccache
|
||||
ports:
|
||||
- port: 9000
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
The allow-list, rule by rule:
|
||||
|
||||
- **Rule 1 - DNS.** UDP/TCP 53 to CoreDNS (`10.43.0.10/32`) and the `kube-system`
|
||||
namespace. Without this, name resolution breaks and rule 2 is useless.
|
||||
- **Rule 2 - public internet only.** `0.0.0.0/0` with an `except` list that
|
||||
carves out every range a job has no business reaching: RFC1918 private space
|
||||
(`10/8`, `172.16/12`, `192.168/16`), link-local (`169.254/16`), the CGNAT range
|
||||
used by the **tailnet** (`100.64.0.0/10`), and the **host's own public IP**
|
||||
(`<PUBLIC_IP>/32`). Note `10.0.0.0/8` covers the pod CIDR (`10.42.0.0/16`) and
|
||||
service CIDR (`10.43.0.0/16`), so this rule alone gives a job **zero** in-cluster
|
||||
reach - rules 1 and 3 punch the only two holes the job legitimately needs.
|
||||
- **Rule 3 - RustFS cache.** TCP 9000 to the service CIDR (`10.43.0.0/16`) and the
|
||||
`sccache` namespace - the sccache backend from [step 5](#5-shared-caches-rustfs-s3--runner-pvc).
|
||||
- **Ingress.** `policyTypes` lists `Ingress` but no ingress rule is defined, which
|
||||
is a **default-deny**: nothing can open a connection *into* a runner pod.
|
||||
|
||||
Egress that survives rule 2 leaves the node via the host's firewalld masquerade
|
||||
(SNAT to the public IP) over the default interface - see
|
||||
[01-host-and-cluster.md](01-host-and-cluster.md) for the host firewall side.
|
||||
|
||||
### Security model
|
||||
|
||||
- **Kernel isolation.** Each job runs in a Kata microVM with its own guest kernel
|
||||
(6.x), separate from the host kernel (7.0.x) - a kernel exploit hits a throwaway
|
||||
VM, not the host. See [02-kata-runtime.md](02-kata-runtime.md).
|
||||
- **No cluster rights.** Jobs run under `omp-kata-gha-rs-no-permission` with no
|
||||
RBAC ([step 4](#4-job-lifecycle-and-the-no-permission-serviceaccount)).
|
||||
- **Constrained network.** The policy above blocks the host, LAN, tailnet, and
|
||||
arbitrary cluster pods; only DNS, the public internet, and RustFS are reachable.
|
||||
- **Ephemeral.** One job per VM, destroyed afterward - no state, secret, or
|
||||
artifact survives into the next job.
|
||||
- **Public-repo recommendation.** For a public repo, require approval for fork
|
||||
PRs so untrusted code cannot auto-run on the infra: **repo - Settings - Actions
|
||||
- General - Fork pull request workflows from outside collaborators - Require
|
||||
approval for all outside collaborators**.
|
||||
|
||||
---
|
||||
|
||||
## 7. Operate
|
||||
|
||||
```bash
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
```
|
||||
|
||||
**Status / scale**
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners get autoscalingrunnerset omp-kata # min/max/current runners
|
||||
kubectl -n arc-runners get ephemeralrunnerset # desired vs current replicas
|
||||
kubectl -n arc-runners get pods -o wide # live runner VMs (empty when idle)
|
||||
```
|
||||
|
||||
**Logs**
|
||||
|
||||
```bash
|
||||
# Listener (job dispatch / scaling decisions)
|
||||
kubectl -n arc-systems logs -l app.kubernetes.io/component=runner-scale-set-listener -f
|
||||
# Controller (reconciliation)
|
||||
kubectl -n arc-systems logs deploy/arc-gha-rs-controller -f
|
||||
# A specific runner / its job
|
||||
kubectl -n arc-runners logs <runner-pod>
|
||||
```
|
||||
|
||||
**Verify the caches are being used.** A warm job logs
|
||||
`bun cache backend: mounted PVC (...)` and
|
||||
`sccache backend: shared S3 (sccache @ rustfs.sccache.svc.cluster.local:9000)` in
|
||||
its step output. To inspect the mounted cache, scale to zero and check the
|
||||
`runner-cache` local-path volume on the host; to inspect sccache objects, point
|
||||
an S3 client at RustFS and list `s3://sccache/`.
|
||||
|
||||
**Resize a job's VM** - edit the `resources` block in `arc-omp-values.yaml`
|
||||
([step 3](#3-scale-set-values-arc-omp-valuesyaml); requests = guaranteed VM size,
|
||||
limits = hotplug ceiling) and roll out:
|
||||
|
||||
```bash
|
||||
helm upgrade omp-kata \
|
||||
--namespace arc-runners --version 0.14.2 \
|
||||
-f arc-omp-values.yaml \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
|
||||
```
|
||||
|
||||
**Change scale-to-zero bounds** - edit `minRunners` / `maxRunners` in the same
|
||||
file and `helm upgrade` as above. (Keep `maxRunners` within the node's CPU/RAM
|
||||
budget: each runner can hotplug up to its `limits`.)
|
||||
|
||||
**Update the runner image** - bump `template.spec.containers[0].image` to the new
|
||||
tag, then `helm upgrade` as above; confirm with:
|
||||
|
||||
```bash
|
||||
kubectl -n arc-runners get autoscalingrunnerset omp-kata \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
|
||||
```
|
||||
|
||||
See [03-runner-image.md](03-runner-image.md) for building and importing the image.
|
||||
|
||||
**Add another repo.** Because the GitHub App installation can cover multiple repos,
|
||||
reuse the same `arc-github` secret and install a second scale set with its own
|
||||
`githubConfigUrl`, `runnerScaleSetName` (the new `runs-on:` label), and release
|
||||
name:
|
||||
|
||||
```bash
|
||||
helm install <release> \
|
||||
--namespace arc-runners --version 0.14.2 \
|
||||
--set githubConfigUrl=https://github.com/<OWNER>/<OTHER_REPO> \
|
||||
--set githubConfigSecret=arc-github \
|
||||
--set runnerScaleSetName=<other-repo>-kata \
|
||||
-f arc-omp-values.yaml \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
|
||||
```
|
||||
|
||||
Jobs in the other repo then target `runs-on: <other-repo>-kata`. (On this host a
|
||||
convenience wrapper, `omp-add-repo-runner <OWNER>/<REPO> [label]`, performs exactly
|
||||
this install.)
|
||||
|
||||
**Uninstall** (leaves k3s/Kata in place):
|
||||
|
||||
```bash
|
||||
helm uninstall omp-kata -n arc-runners
|
||||
helm uninstall arc -n arc-systems
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Previous:** [03-runner-image.md](03-runner-image.md) - the preloaded runner image.
|
||||
**Overview:** [README.md](README.md) - architecture and the full doc set.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Self-hosted Kata CI
|
||||
|
||||
This is a self-hosted GitHub Actions setup where **every CI job on the self-hosted `omp-kata` label runs inside its own throwaway Kata Containers QEMU/KVM microVM**. A single bare-metal Linux host runs a one-node [k3s](https://k3s.io) cluster; [actions-runner-controller (ARC)](https://github.com/actions/actions-runner-controller) watches GitHub for queued jobs and, for each one, creates a just-in-time ephemeral runner pod that boots a fresh microVM (its own guest kernel, isolated from the host), runs exactly one job, and is then destroyed. Pull requests deliberately run on GitHub-hosted runners instead, so the self-hosted fleet only serves trusted `push`/main + release builds and untrusted PR code never reaches the shared caches (see [04-arc-and-caching.md](04-arc-and-caching.md)). Runners share an in-cluster **RustFS** (S3-compatible) object store for `sccache`, plus a namespace-local PVC mounted as the Bun package store and Cargo registry cache. Public internet egress goes through host NAT under a restrictive NetworkPolicy. The result is hardware-isolated, scale-to-zero CI on hardware you control.
|
||||
|
||||
These docs are written as a **from-scratch setup guide**: read this overview first, then follow the numbered guides in order to reproduce the system on your own host.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
GH["GitHub Actions<br/>repo <OWNER>/<REPO> + GitHub App"]
|
||||
|
||||
subgraph HOST["CI host (<CI_HOST>) — CentOS Stream 10, KVM/bare-metal"]
|
||||
NAT["firewalld masquerade<br/>egress NAT → public IP <PUBLIC_IP>"]
|
||||
subgraph K3S["single-node k3s v1.35.5 (own containerd v2)"]
|
||||
subgraph SYS["ns: arc-systems"]
|
||||
LIS["Runner scale-set listener<br/>long-polls GitHub"]
|
||||
CTRL["ARC controller 0.14.2<br/>scales EphemeralRunnerSet"]
|
||||
end
|
||||
subgraph RUN["ns: arc-runners"]
|
||||
NP["NetworkPolicy<br/>runner-egress-lockdown"]
|
||||
POD["Ephemeral runner pod (JIT)<br/>runtimeClassName: kata-qemu"]
|
||||
subgraph VM["Kata QEMU/KVM microVM — separate guest kernel"]
|
||||
JOB["actions/runner + one job's steps"]
|
||||
end
|
||||
end
|
||||
subgraph CACHE["shared caches"]
|
||||
RUSTFS["ns: sccache<br/>RustFS (S3) svc rustfs:9000 · PVC rustfs-data 100Gi"]
|
||||
PVC["ns: arc-runners<br/>PVC runner-cache 100Gi<br/>Bun store + Cargo registry"]
|
||||
end
|
||||
SEC["Secret sccache-s3<br/>S3 creds + endpoint"]
|
||||
end
|
||||
end
|
||||
|
||||
GH <-->|"long-poll / JIT registration"| LIS
|
||||
LIS --> CTRL
|
||||
CTRL -->|"creates 1 pod per job"| POD
|
||||
POD --> VM
|
||||
SEC -.->|"envFrom"| POD
|
||||
NP -.->|"filters egress"| POD
|
||||
JOB -->|"sccache (S3 SigV4)"| RUSTFS
|
||||
JOB -->|"Bun store + Cargo registry mounts"| PVC
|
||||
POD -->|"allowed egress"| NAT
|
||||
NAT -->|"checkout / API / internet"| GH
|
||||
```
|
||||
|
||||
Key properties baked into this design:
|
||||
|
||||
- **One job = one VM.** Runner pods are ephemeral and JIT-registered; there is no VM templating or pooling, so a job never inherits state from a previous job.
|
||||
- **Scale-to-zero.** `minRunners: 0` / `maxRunners: 10` — when no jobs are queued, zero runner pods (and zero microVMs) exist.
|
||||
- **Host-kernel isolation.** Jobs see the microVM's guest kernel, not the host kernel, so a kernel exploit in a job does not reach the host.
|
||||
- **No external registry.** The runner image is built on the host and imported straight into k3s' containerd.
|
||||
- **Shared, in-cluster cache.** `sccache` targets RustFS over the cluster network; Bun's package store and Cargo's registry cache are mounted from the runner cache PVC. Cache traffic stays on the host.
|
||||
|
||||
## End-to-end job lifecycle
|
||||
|
||||
1. A workflow job targeting the self-hosted label (`runs-on:`) is **queued** on GitHub.
|
||||
2. The **scale-set listener** in `arc-systems` is long-polling the GitHub Actions service and receives the job-assignment message.
|
||||
3. The listener signals demand to the **ARC controller**, which scales the **EphemeralRunnerSet** up by one.
|
||||
4. The controller creates a single **JIT-registered ephemeral runner pod** in `arc-runners`, with `runtimeClassName: kata-qemu` and the `sccache-s3` secret injected via `envFrom`.
|
||||
5. containerd hands the pod to the Kata shim, which **boots a fresh QEMU/KVM microVM** (own guest kernel; the container rootfs is shared in over virtio-fs). No templating — every job gets a clean VM.
|
||||
6. The runner agent inside the microVM **registers just-in-time and picks up exactly one job**. Steps run isolated from the host, using RustFS over S3 for `sccache`, mounted PVC paths for Bun/Cargo package caches, and NAT egress for the public internet, all constrained by the `runner-egress-lockdown` NetworkPolicy.
|
||||
7. The job finishes; the ephemeral runner **deregisters and the pod (and its microVM) is destroyed** — never reused.
|
||||
8. When no jobs remain queued, the EphemeralRunnerSet **scales back to zero**, leaving no idle runners or VMs.
|
||||
|
||||
## Component map (bill of materials)
|
||||
|
||||
| Component | What it is | Version | Documented in |
|
||||
| --- | --- | --- | --- |
|
||||
| Host + k3s cluster | Bare-metal CentOS Stream 10 node running single-node k3s (own containerd v2, Flannel CNI; Traefik + servicelb disabled so host nginx keeps :80/:443); firewalld provides NAT egress | k3s `v1.35.5+k3s1` | [01-host-and-cluster.md](01-host-and-cluster.md) |
|
||||
| Kata Containers runtime | QEMU/KVM microVM runtime: containerd drop-in registering `kata-qemu` + the `kata-qemu` RuntimeClass | Kata `3.31.0` | [02-kata-runtime.md](02-kata-runtime.md) |
|
||||
| Preloaded runner image | Custom `actions/runner` image (build toolchain, Bun, Rust nightly + cross targets, native-build deps) built on the host and imported into k3s containerd — no registry | local dated tag | [03-runner-image.md](03-runner-image.md) |
|
||||
| ARC (runner scale set) | actions-runner-controller, `gha-runner-scale-set` flavor: controller in `arc-systems`, one scale set + listener, GitHub App auth | ARC `0.14.2` | [04-arc-and-caching.md](04-arc-and-caching.md) |
|
||||
| Shared caches | RustFS S3 (`svc rustfs:9000`, 100Gi PVC) backs `sccache`; `arc-runners/runner-cache` (100Gi PVC) mounts Bun's package store and Cargo's registry cache into runner pods; the `sccache-s3` secret and egress NetworkPolicy wire access | in-cluster services/storage | [04-arc-and-caching.md](04-arc-and-caching.md) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, you need:
|
||||
|
||||
- **A Linux host with hardware virtualization.** Intel VT-x or AMD-V enabled, KVM available (`/dev/kvm` present and accessible). Bare metal is simplest; on a VM you need working nested virtualization. The reference host is 32 vCPU / 125 GiB RAM — size to roughly `maxRunners × per-job resources` plus cluster overhead.
|
||||
- **Root (or full sudo)** on that host: you will install k3s, Kata, kernel modules, firewall rules, and a container image.
|
||||
- **A public-ish egress path.** The host must reach GitHub; runner microVMs NAT out through the host's public IP. No inbound ports are required for the runners (the listener uses outbound long-poll).
|
||||
- **A GitHub repository** to attach runners to, and a **GitHub App** (recommended) or PAT installed on it with permissions to manage self-hosted runners. You will record the App ID, installation ID, and private key as a Kubernetes secret.
|
||||
- **CLI tooling on the host:** `kubectl` and `helm` (k3s bundles a kubectl), plus Docker/buildkit for building the runner image (see [03-runner-image.md](03-runner-image.md)).
|
||||
|
||||
## Redaction & placeholders
|
||||
|
||||
The configs in this doc set are copied verbatim from the live host and then redacted. Wherever you see one of these tokens, substitute your own value:
|
||||
|
||||
| Placeholder | Substitute with |
|
||||
| --- | --- |
|
||||
| `<CI_HOST>` | Your CI host's hostname / SSH target |
|
||||
| `<PUBLIC_IP>` | The host's public IPv4 address |
|
||||
| `<TAILNET_IP>` | Your Tailscale/tailnet admin IP(s) (the generic CGNAT range `100.64.0.0/10` is kept as-is) |
|
||||
| `<OWNER>/<REPO>` | Your GitHub repository owner and name |
|
||||
| `<GITHUB_APP_ID>` | Your GitHub App ID |
|
||||
| `<GITHUB_APP_INSTALLATION_ID>` | Your GitHub App installation ID |
|
||||
| `<GITHUB_APP_PRIVATE_KEY>` | Your GitHub App private key (PEM) |
|
||||
| `<S3_ACCESS_KEY>` | RustFS/S3 access key ID |
|
||||
| `<S3_SECRET_KEY>` | RustFS/S3 secret access key |
|
||||
| `<PLACEHOLDER>` | Any other password/key/token (named in context where it appears) |
|
||||
|
||||
Secret **values** never appear in these docs — only key names and placeholders. The following are intentionally **kept as-is** because they are not sensitive and are needed to follow along: the pod CIDR `10.42.0.0/16`, the service CIDR `10.43.0.0/16`, the CoreDNS service IP `10.43.0.10`, the bucket name `sccache`, in-cluster service DNS names and ports, and all version numbers.
|
||||
|
||||
## Recommended setup order
|
||||
|
||||
Work through the numbered guides in order — each builds on the previous:
|
||||
|
||||
1. **[01-host-and-cluster.md](01-host-and-cluster.md)** — Host prep (KVM, firewall/NAT) and the single-node k3s install, networking, and CNI.
|
||||
2. **[02-kata-runtime.md](02-kata-runtime.md)** — Install Kata Containers, wire it into k3s' containerd, and register the `kata-qemu` RuntimeClass.
|
||||
3. **[03-runner-image.md](03-runner-image.md)** — Build the preloaded runner image and import it into k3s containerd.
|
||||
4. **[04-arc-and-caching.md](04-arc-and-caching.md)** — Install ARC and the runner scale set, deploy RustFS for `sccache`, add the runner cache PVC for Bun/Cargo, wire up the `sccache-s3` secret, and apply the egress NetworkPolicy.
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build + roll the preloaded omp-kata runner image onto the self-hosted CI host,
|
||||
# driven over SSH from this repo. The Dockerfile next to this script is the
|
||||
# source of truth: it is copied to the host, built there, and the ARC runner
|
||||
# scale set is pointed at the new tag and rolled.
|
||||
#
|
||||
# By default the remote side prefers a direct containerd build path: bootstrap a
|
||||
# pinned BuildKit + nerdctl toolchain under the remote build dir, build straight
|
||||
# into the k3s containerd `k8s.io` namespace, and smoke-test from that image
|
||||
# store. That avoids the old `docker save | ctr images import` tarball hop and
|
||||
# cuts duplicate layer I/O on large reloads. If the direct path is unavailable
|
||||
# or you set BUILD_BACKEND=docker, it falls back to the legacy Docker-daemon
|
||||
# build + import path.
|
||||
#
|
||||
# The host is intentionally NOT hardcoded (this repo is public). Set CI_HOST to
|
||||
# your ssh target; the remaining knobs default to the reference deployment.
|
||||
#
|
||||
# Usage:
|
||||
# CI_HOST=my-ci-host ./infra/reload-runner.sh # tag: omp-kata-runner:YYYY-MM-DD-HHMMSS
|
||||
# CI_HOST=my-ci-host ./infra/reload-runner.sh 2026-06-20 # tag: omp-kata-runner:2026-06-20
|
||||
# CI_HOST=my-ci-host ./infra/reload-runner.sh my/repo:tag # explicit repo:tag
|
||||
#
|
||||
# Env knobs (defaults match the reference deployment):
|
||||
# CI_HOST ssh target of the CI host (required)
|
||||
# REMOTE_CTX remote build dir for the Dockerfile [/root/omp-kata-runner-image]
|
||||
# ARC_VALUES remote ARC scale-set helm values file [/root/arc-omp-values.yaml]
|
||||
# ARC_RELEASE helm release name of the runner scale set [omp-kata]
|
||||
# ARC_NAMESPACE namespace the runner scale set lives in [arc-runners]
|
||||
# ARC_CHART_VERSION gha-runner-scale-set chart version [0.14.2]
|
||||
# KUBECONFIG_REMOTE kubeconfig path on the host [/etc/rancher/k3s/k3s.yaml]
|
||||
# BUILD_BACKEND auto | containerd | docker [auto]
|
||||
# CONTAINERD_SOCKET_REMOTE remote containerd socket [/run/k3s/containerd/containerd.sock]
|
||||
# NERDCTL_VERSION nerdctl release to bootstrap on demand [2.1.6]
|
||||
# BUILDKIT_VERSION BuildKit release to bootstrap on demand [0.25.1]
|
||||
set -euo pipefail
|
||||
|
||||
: "${CI_HOST:?set CI_HOST to the ssh target of your CI host, e.g. CI_HOST=my-ci-host}"
|
||||
REMOTE_CTX="${REMOTE_CTX:-/root/omp-kata-runner-image}"
|
||||
ARC_VALUES="${ARC_VALUES:-/root/arc-omp-values.yaml}"
|
||||
ARC_RELEASE="${ARC_RELEASE:-omp-kata}"
|
||||
ARC_NAMESPACE="${ARC_NAMESPACE:-arc-runners}"
|
||||
ARC_CHART_VERSION="${ARC_CHART_VERSION:-0.14.2}"
|
||||
KUBECONFIG_REMOTE="${KUBECONFIG_REMOTE:-/etc/rancher/k3s/k3s.yaml}"
|
||||
BUILD_BACKEND="${BUILD_BACKEND:-auto}"
|
||||
CONTAINERD_SOCKET_REMOTE="${CONTAINERD_SOCKET_REMOTE:-/run/k3s/containerd/containerd.sock}"
|
||||
NERDCTL_VERSION="${NERDCTL_VERSION:-2.1.6}"
|
||||
BUILDKIT_VERSION="${BUILDKIT_VERSION:-0.25.1}"
|
||||
|
||||
arg="${1:-$(date +%Y-%m-%d-%H%M%S)}"
|
||||
case "$arg" in *:*) IMAGE="$arg";; *) IMAGE="omp-kata-runner:$arg";; esac
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
[ -f "$here/runner.Dockerfile" ] || { echo "no runner.Dockerfile next to $0" >&2; exit 1; }
|
||||
|
||||
echo "==> [0/5] copying Dockerfile to ${CI_HOST}:${REMOTE_CTX}"
|
||||
ssh "$CI_HOST" "mkdir -p '$REMOTE_CTX'"
|
||||
scp -q "$here/runner.Dockerfile" "${CI_HOST}:${REMOTE_CTX}/Dockerfile"
|
||||
|
||||
# All build/import/rollout steps run on the host. Config is passed as positional
|
||||
# args (no secrets, no spaces) so it survives the ssh command-string re-parse
|
||||
# regardless of the host's login shell.
|
||||
ssh "$CI_HOST" bash -s -- \
|
||||
"$IMAGE" "$REMOTE_CTX" "$ARC_VALUES" "$ARC_RELEASE" "$ARC_NAMESPACE" "$ARC_CHART_VERSION" \
|
||||
"$KUBECONFIG_REMOTE" "$BUILD_BACKEND" "$CONTAINERD_SOCKET_REMOTE" "$NERDCTL_VERSION" "$BUILDKIT_VERSION" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
IMAGE="$1"; REMOTE_CTX="$2"; ARC_VALUES="$3"; ARC_RELEASE="$4"; ARC_NAMESPACE="$5"; ARC_CHART_VERSION="$6"
|
||||
export KUBECONFIG="$7"
|
||||
BUILD_BACKEND="$8"; CONTAINERD_SOCKET="$9"; NERDCTL_VERSION="${10}"; BUILDKIT_VERSION="${11}"
|
||||
cd "$REMOTE_CTX"
|
||||
|
||||
TOOLS_DIR="$REMOTE_CTX/.containerd-build-tools"
|
||||
BIN_DIR="$TOOLS_DIR/bin"
|
||||
RUN_DIR="$TOOLS_DIR/run"
|
||||
ROOT_DIR="$TOOLS_DIR/root"
|
||||
LOG_DIR="$TOOLS_DIR/log"
|
||||
NERDCTL_BIN="$BIN_DIR/nerdctl"
|
||||
BUILDKITD_BIN="$BIN_DIR/buildkitd"
|
||||
BUILDKITCTL_BIN="$BIN_DIR/buildctl"
|
||||
BUILDKIT_ADDR="unix://$RUN_DIR/buildkitd.sock"
|
||||
|
||||
BUILDKITD_PID=""
|
||||
cleanup_buildkitd() {
|
||||
if [ -n "${BUILDKITD_PID:-}" ]; then
|
||||
kill "$BUILDKITD_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup_buildkitd EXIT
|
||||
extract_bin() {
|
||||
local archive="$1" needle="$2" out="$3" tmp
|
||||
tmp="$(mktemp -d)"
|
||||
tar -C "$tmp" -xf "$archive"
|
||||
cp "$(find "$tmp" -type f -name "$needle" | head -1)" "$out"
|
||||
chmod +x "$out"
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
bootstrap_containerd_tools() {
|
||||
mkdir -p "$BIN_DIR" "$RUN_DIR" "$ROOT_DIR" "$LOG_DIR"
|
||||
if [ ! -S "$CONTAINERD_SOCKET" ]; then
|
||||
echo "containerd socket missing: $CONTAINERD_SOCKET" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ ! -x "$NERDCTL_BIN" ]; then
|
||||
local archive="$TOOLS_DIR/nerdctl-${NERDCTL_VERSION}.tar.gz"
|
||||
echo "==> [1/5] bootstrapping nerdctl ${NERDCTL_VERSION}"
|
||||
curl -fsSL "https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-${NERDCTL_VERSION}-linux-amd64.tar.gz" -o "$archive"
|
||||
extract_bin "$archive" nerdctl "$NERDCTL_BIN"
|
||||
fi
|
||||
if [ ! -x "$BUILDKITD_BIN" ] || [ ! -x "$BUILDKITCTL_BIN" ]; then
|
||||
local archive="$TOOLS_DIR/buildkit-v${BUILDKIT_VERSION}.tar.gz"
|
||||
echo "==> [1/5] bootstrapping BuildKit ${BUILDKIT_VERSION}"
|
||||
curl -fsSL "https://github.com/moby/buildkit/releases/download/v${BUILDKIT_VERSION}/buildkit-v${BUILDKIT_VERSION}.linux-amd64.tar.gz" -o "$archive"
|
||||
extract_bin "$archive" buildkitd "$BUILDKITD_BIN"
|
||||
extract_bin "$archive" buildctl "$BUILDKITCTL_BIN"
|
||||
fi
|
||||
}
|
||||
|
||||
start_buildkitd() {
|
||||
rm -f "$RUN_DIR/buildkitd.sock"
|
||||
"$BUILDKITD_BIN" \
|
||||
--addr "$BUILDKIT_ADDR" \
|
||||
--root "$ROOT_DIR" \
|
||||
--containerd-worker=true \
|
||||
--containerd-worker-namespace k8s.io \
|
||||
--containerd-worker-addr "$CONTAINERD_SOCKET" \
|
||||
--oci-worker=false >"$LOG_DIR/buildkitd.log" 2>&1 &
|
||||
BUILDKITD_PID="$!"
|
||||
for _ in $(seq 1 120); do
|
||||
[ -S "$RUN_DIR/buildkitd.sock" ] && return 0
|
||||
sleep 0.25
|
||||
done
|
||||
echo "buildkitd did not create $RUN_DIR/buildkitd.sock" >&2
|
||||
sed -n '1,120p' "$LOG_DIR/buildkitd.log" >&2 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
verify_baked_tools() {
|
||||
local runner="$1"
|
||||
"$runner" --namespace k8s.io run --rm --entrypoint bash "$IMAGE" -lc '
|
||||
set -e
|
||||
for b in gh fd rg magick bun cargo rustc pkg-config clang lld sccache zig cargo-nextest cargo-zigbuild cargo-xwin; do
|
||||
command -v "$b" >/dev/null || { echo "MISSING: $b"; exit 1; }
|
||||
done
|
||||
echo "tools OK | bun $(bun --version) | rust $(rustc --version) | sccache $(set -- $(sccache --version); echo "$2") | zig $(zig version) | gh $(set -- $(gh --version | head -1); echo "$3")"
|
||||
'
|
||||
}
|
||||
|
||||
build_with_containerd() {
|
||||
bootstrap_containerd_tools
|
||||
start_buildkitd
|
||||
|
||||
echo "==> [2/5] building $IMAGE directly into k3s containerd (k8s.io namespace)"
|
||||
"$BUILDKITCTL_BIN" --addr "$BUILDKIT_ADDR" build \
|
||||
--progress=plain \
|
||||
--frontend dockerfile.v0 \
|
||||
--local context=. \
|
||||
--local dockerfile=. \
|
||||
--opt filename=Dockerfile \
|
||||
--output "type=image,name=$IMAGE,store=true"
|
||||
k3s ctr -n k8s.io images tag "$IMAGE" omp-kata-runner:preloaded >/dev/null 2>&1 || true
|
||||
|
||||
echo "==> [3/5] verifying baked tools from k3s containerd"
|
||||
verify_baked_tools "$NERDCTL_BIN"
|
||||
}
|
||||
|
||||
build_with_docker() {
|
||||
echo "==> [1/5] building $IMAGE with docker"
|
||||
DOCKER_BUILDKIT=1 docker build -t "$IMAGE" -t omp-kata-runner:preloaded .
|
||||
|
||||
echo "==> [2/5] verifying baked tools"
|
||||
docker run --rm --entrypoint bash "$IMAGE" -lc '
|
||||
set -e
|
||||
for b in gh fd rg magick bun cargo rustc pkg-config clang lld sccache zig cargo-nextest cargo-zigbuild cargo-xwin; do
|
||||
command -v "$b" >/dev/null || { echo "MISSING: $b"; exit 1; }
|
||||
done
|
||||
echo "tools OK | bun $(bun --version) | rust $(rustc --version) | sccache $(set -- $(sccache --version); echo "$2") | zig $(zig version) | gh $(set -- $(gh --version | head -1); echo "$3")"
|
||||
'
|
||||
|
||||
echo "==> [3/5] importing into k3s containerd (k8s.io namespace)"
|
||||
docker save "$IMAGE" | k3s ctr -n k8s.io images import --platform linux/amd64 -
|
||||
}
|
||||
|
||||
selected_backend="$BUILD_BACKEND"
|
||||
case "$BUILD_BACKEND" in
|
||||
auto)
|
||||
if bootstrap_containerd_tools >/dev/null 2>&1; then
|
||||
selected_backend=containerd
|
||||
else
|
||||
selected_backend=docker
|
||||
fi
|
||||
;;
|
||||
containerd|docker)
|
||||
;;
|
||||
*)
|
||||
echo "BUILD_BACKEND must be auto, containerd, or docker (got: $BUILD_BACKEND)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "==> selected build backend: $selected_backend"
|
||||
case "$selected_backend" in
|
||||
containerd)
|
||||
if ! build_with_containerd; then
|
||||
if [ "$BUILD_BACKEND" = auto ]; then
|
||||
echo "==> containerd build path failed; falling back to docker" >&2
|
||||
build_with_docker
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
docker)
|
||||
build_with_docker
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "==> [4/5] pointing ARC runner scale set at $IMAGE"
|
||||
sed -i "s#image: omp-kata-runner:.*#image: $IMAGE#" "$ARC_VALUES"
|
||||
helm upgrade "$ARC_RELEASE" --namespace "$ARC_NAMESPACE" --version "$ARC_CHART_VERSION" \
|
||||
-f "$ARC_VALUES" \
|
||||
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set >/dev/null
|
||||
|
||||
echo "==> [5/5] verifying rollout"
|
||||
live="$(kubectl get autoscalingrunnerset "$ARC_RELEASE" -n "$ARC_NAMESPACE" \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}')"
|
||||
echo "ARC runner image is now: $live"
|
||||
[ "$live" = "$IMAGE" ] && echo "OK: reloaded $IMAGE" || { echo "MISMATCH: expected $IMAGE"; exit 1; }
|
||||
REMOTE
|
||||
|
||||
echo "OK: $IMAGE built on ${CI_HOST}, stored in k3s containerd, and rolled out to ARC."
|
||||
@@ -0,0 +1,76 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Preloaded omp-kata runner image.
|
||||
#
|
||||
# Stock GitHub Actions runner (Ubuntu 24.04) with the dependencies CI installs
|
||||
# on every job baked in, so each ephemeral Kata microVM boots with them already
|
||||
# present instead of re-fetching them per job:
|
||||
# - APT system deps (canvas/cairo stack + fd/ripgrep/imagemagick) + fd/magick shims
|
||||
# - GitHub CLI (gh) — present on GitHub-hosted runners; the coding-agent github
|
||||
# tool and release workflows expect it
|
||||
# - C/build toolchain the native + canvas builds need
|
||||
# - bun (system-wide, on PATH)
|
||||
# - sccache + Zig + cargo-nextest/cargo-zigbuild/cargo-xwin for native builds
|
||||
# - rust nightly (pinned) + clippy/rustfmt/rust-analyzer + linux-arm64/windows-msvc targets
|
||||
#
|
||||
# Rebuild + reimport (see /root/omp-kata-runner.md) after bumping the ARGs below
|
||||
# or the apt set. Keep the apt set in sync with .github/actions/setup-system-deps.
|
||||
FROM ghcr.io/actions/actions-runner:latest
|
||||
|
||||
ARG RUST_NIGHTLY=nightly-2026-04-29
|
||||
ARG BUN_VERSION=1.3.14
|
||||
ARG SCCACHE_VERSION=0.15.0
|
||||
ARG ZIG_VERSION=0.16.0
|
||||
|
||||
USER root
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Mirrors the "Install system deps" block in .github/workflows/ci.yml plus the
|
||||
# native/cross toolchain (clang/lld/llvm), the baked cache/tooling binaries, and
|
||||
# the GitHub CLI. The gh apt repo is added first so `gh` installs in the same apt
|
||||
# transaction.
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y \
|
||||
build-essential pkg-config curl ca-certificates git unzip xz-utils gh \
|
||||
clang lld llvm \
|
||||
libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev \
|
||||
fd-find ripgrep imagemagick \
|
||||
&& ln -sf "$(command -v fdfind)" /usr/local/bin/fd \
|
||||
&& ln -sf /usr/bin/convert /usr/local/bin/magick \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# bun, system-wide (BUN_INSTALL/bin == /usr/local/bin, already on PATH).
|
||||
ENV BUN_INSTALL=/usr/local
|
||||
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
|
||||
&& bun --version
|
||||
|
||||
# Pinned native-build helpers, system-wide.
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz -C /tmp \
|
||||
&& install -m755 "/tmp/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache \
|
||||
&& rm -rf "/tmp/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl"
|
||||
RUN curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \
|
||||
&& tar -xJf /tmp/zig.tar.xz -C /opt \
|
||||
&& ln -sf "/opt/zig-x86_64-linux-${ZIG_VERSION}/zig" /usr/local/bin/zig \
|
||||
&& rm -f /tmp/zig.tar.xz
|
||||
|
||||
# rust toolchain + cargo helpers for the runner user; rustup default == pinned
|
||||
# nightly so Rust setup becomes a no-op on the preloaded image.
|
||||
USER runner
|
||||
ENV RUSTUP_HOME=/home/runner/.rustup \
|
||||
CARGO_HOME=/home/runner/.cargo \
|
||||
PATH=/home/runner/.cargo/bin:/usr/local/bin:${PATH}
|
||||
RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain "${RUST_NIGHTLY}" --profile minimal \
|
||||
&& rustup component add clippy rustfmt rust-analyzer \
|
||||
&& rustup target add aarch64-unknown-linux-gnu x86_64-pc-windows-msvc \
|
||||
&& cargo install --locked cargo-nextest cargo-zigbuild cargo-xwin \
|
||||
&& cargo --version \
|
||||
&& rustc --version \
|
||||
&& sccache --version \
|
||||
&& zig version \
|
||||
&& cargo-nextest --version \
|
||||
&& cargo-zigbuild --help >/dev/null \
|
||||
&& cargo-xwin --help >/dev/null
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Patch the live Kata QEMU config on the CI host to match the runner pod's
|
||||
# guaranteed boot shape and a larger virtiofsd worker pool, then smoke-test that
|
||||
# a new kata-qemu pod still boots. Driven over SSH from this repo so the desired
|
||||
# values stay version-controlled.
|
||||
#
|
||||
# Usage:
|
||||
# CI_HOST=my-ci-host ./infra/tune-kata-runtime.sh
|
||||
#
|
||||
# Env knobs:
|
||||
# CI_HOST ssh target of the CI host (required)
|
||||
# KATA_CONFIG_REMOTE remote Kata config file [/opt/kata/share/defaults/kata-containers/configuration-qemu.toml]
|
||||
# KUBECONFIG_REMOTE kubeconfig path on the host [/etc/rancher/k3s/k3s.yaml]
|
||||
# ARC_RELEASE runner scale set name [omp-kata]
|
||||
# ARC_NAMESPACE runner namespace [arc-runners]
|
||||
# BOOT_VCPUS Kata default_vcpus [2]
|
||||
# BOOT_MEMORY_MIB Kata default_memory (MiB) [4096]
|
||||
# VIRTIOFSD_THREAD_POOL virtiofsd --thread-pool-size [4]
|
||||
set -euo pipefail
|
||||
|
||||
: "${CI_HOST:?set CI_HOST to the ssh target of your CI host, e.g. CI_HOST=my-ci-host}"
|
||||
KATA_CONFIG_REMOTE="${KATA_CONFIG_REMOTE:-/opt/kata/share/defaults/kata-containers/configuration-qemu.toml}"
|
||||
KUBECONFIG_REMOTE="${KUBECONFIG_REMOTE:-/etc/rancher/k3s/k3s.yaml}"
|
||||
ARC_RELEASE="${ARC_RELEASE:-omp-kata}"
|
||||
ARC_NAMESPACE="${ARC_NAMESPACE:-arc-runners}"
|
||||
BOOT_VCPUS="${BOOT_VCPUS:-2}"
|
||||
BOOT_MEMORY_MIB="${BOOT_MEMORY_MIB:-4096}"
|
||||
VIRTIOFSD_THREAD_POOL="${VIRTIOFSD_THREAD_POOL:-4}"
|
||||
|
||||
ssh "$CI_HOST" bash -s -- \
|
||||
"$KATA_CONFIG_REMOTE" "$KUBECONFIG_REMOTE" "$ARC_RELEASE" "$ARC_NAMESPACE" \
|
||||
"$BOOT_VCPUS" "$BOOT_MEMORY_MIB" "$VIRTIOFSD_THREAD_POOL" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
KATA_CONFIG="$1"
|
||||
export KUBECONFIG="$2"
|
||||
ARC_RELEASE="$3"
|
||||
ARC_NAMESPACE="$4"
|
||||
BOOT_VCPUS="$5"
|
||||
BOOT_MEMORY_MIB="$6"
|
||||
THREAD_POOL="$7"
|
||||
|
||||
backup="${KATA_CONFIG}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$KATA_CONFIG" "$backup"
|
||||
echo "==> backup: $backup"
|
||||
|
||||
python3 - "$KATA_CONFIG" "$BOOT_VCPUS" "$BOOT_MEMORY_MIB" "$THREAD_POOL" <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
path = Path(sys.argv[1])
|
||||
boot_vcpus = sys.argv[2]
|
||||
boot_mem = sys.argv[3]
|
||||
thread_pool = sys.argv[4]
|
||||
text = path.read_text()
|
||||
replacements = [
|
||||
(r'(^\s*default_vcpus\s*=\s*)\d+', rf'\g<1>{boot_vcpus}'),
|
||||
(r'(^\s*default_memory\s*=\s*)\d+', rf'\g<1>{boot_mem}'),
|
||||
(r'(^\s*virtio_fs_extra_args\s*=\s*)\[[^\]]*\]', rf'\g<1>["--thread-pool-size={thread_pool}", "--announce-submounts"]'),
|
||||
]
|
||||
for pattern, replacement in replacements:
|
||||
text, n = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE)
|
||||
if n != 1:
|
||||
raise SystemExit(f"failed to patch {pattern}")
|
||||
path.write_text(text)
|
||||
PY
|
||||
|
||||
echo "==> active Kata knobs"
|
||||
grep -nE 'default_vcpus|default_memory|virtio_fs_extra_args' "$KATA_CONFIG"
|
||||
|
||||
image="$(kubectl get autoscalingrunnerset "$ARC_RELEASE" -n "$ARC_NAMESPACE" -o jsonpath='{.spec.template.spec.containers[0].image}')"
|
||||
pod="kata-runtime-smoke-$(date +%H%M%S)"
|
||||
trap 'kubectl delete pod "$pod" -n "$ARC_NAMESPACE" --ignore-not-found >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
echo "==> smoke boot via kata-qemu using $image"
|
||||
kubectl run "$pod" -n "$ARC_NAMESPACE" --restart=Never --image="$image" \
|
||||
--overrides='{"spec":{"runtimeClassName":"kata-qemu"}}' \
|
||||
--command -- bash -lc 'sleep 120' >/dev/null
|
||||
kubectl wait --for=condition=Ready "pod/$pod" -n "$ARC_NAMESPACE" --timeout=120s >/dev/null
|
||||
kubectl exec -n "$ARC_NAMESPACE" "$pod" -- bash -lc 'bun --version; rustc --version | head -1'
|
||||
echo "OK: kata-qemu still boots after tuning"
|
||||
REMOTE
|
||||
+14
-14
@@ -24,18 +24,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.13.0",
|
||||
"@oh-my-pi/omp-stats": "15.13.0",
|
||||
"@oh-my-pi/pi-agent-core": "15.13.0",
|
||||
"@oh-my-pi/pi-ai": "15.13.0",
|
||||
"@oh-my-pi/pi-catalog": "15.13.0",
|
||||
"@oh-my-pi/pi-coding-agent": "15.13.0",
|
||||
"@oh-my-pi/pi-mnemopi": "15.13.0",
|
||||
"@oh-my-pi/pi-natives": "15.13.0",
|
||||
"@oh-my-pi/pi-tui": "15.13.0",
|
||||
"@oh-my-pi/pi-utils": "15.13.0",
|
||||
"@oh-my-pi/pi-wire": "15.13.0",
|
||||
"@oh-my-pi/snapcompact": "15.13.0",
|
||||
"@oh-my-pi/hashline": "15.13.1",
|
||||
"@oh-my-pi/omp-stats": "15.13.1",
|
||||
"@oh-my-pi/pi-agent-core": "15.13.1",
|
||||
"@oh-my-pi/pi-ai": "15.13.1",
|
||||
"@oh-my-pi/pi-catalog": "15.13.1",
|
||||
"@oh-my-pi/pi-coding-agent": "15.13.1",
|
||||
"@oh-my-pi/pi-mnemopi": "15.13.1",
|
||||
"@oh-my-pi/pi-natives": "15.13.1",
|
||||
"@oh-my-pi/pi-tui": "15.13.1",
|
||||
"@oh-my-pi/pi-utils": "15.13.1",
|
||||
"@oh-my-pi/pi-wire": "15.13.1",
|
||||
"@oh-my-pi/snapcompact": "15.13.1",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -104,7 +104,7 @@
|
||||
"build:native": "bun --cwd=packages/natives run build",
|
||||
"test": "bun run --parallel test:ts test:rs",
|
||||
"test:ts": "GITHUB_ACTIONS= bun run --workspaces --if-present test -- --only-failures && bun run test:scripts",
|
||||
"test:scripts": "bun test scripts/ci-concurrency.test.ts",
|
||||
"test:scripts": "bun test scripts/ci-concurrency.test.ts scripts/ci-release-notes.test.ts",
|
||||
"test:rs": "bun scripts/run-rs-task.ts test:rs",
|
||||
"check": "bun run --parallel check:ts check:rs",
|
||||
"check:ts": "bun run check:tools && bun run --workspaces --if-present check",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ci:test:ts": "bun scripts/ci-test-ts.ts all",
|
||||
"ci:test:ts:workspace": "bun scripts/ci-test-ts.ts workspace",
|
||||
"ci:test:ts:native": "bun scripts/ci-test-ts.ts native",
|
||||
"ci:test:coding-agent:fast": "bun scripts/ci-test-ts.ts coding-agent-fast",
|
||||
"ci:test:coding-agent:singleton": "bun scripts/ci-test-ts.ts coding-agent-singleton",
|
||||
"ci:test:coding-agent:ui": "bun scripts/ci-test-ts.ts coding-agent-ui",
|
||||
"ci:test:coding-agent:runtime": "bun scripts/ci-test-ts.ts coding-agent-runtime",
|
||||
"ci:test:coding-agent:native": "bun scripts/ci-test-ts.ts coding-agent-native",
|
||||
|
||||
+218
-94
@@ -2,68 +2,134 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed `pruneSupersededToolResults` to allow `supersedeKey` to be omitted so useless-result pruning can run without read-style supersede grouping
|
||||
- Removed `compaction/index.ts` re-export of snapcompact helpers, so snapcompact utilities are no longer available from the agent compaction barrel and should be imported from `@oh-my-pi/snapcompact`
|
||||
- Removed the `convertToLlm` alias export from `compaction/messages` — it duplicated `defaultConvertToLlm` under a second name. Import `defaultConvertToLlm` (array form) or the new `convertMessageToLlm` (single-message form) instead
|
||||
## [15.13.1] - 2026-06-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added repetition-loop detection to the streaming agent loop for Gemini-family providers. A runaway run of a repeated text or thinking unit is detected mid-stream from a bounded rolling tail (O(1) per delta), the provider request is aborted, the repeated tail is collapsed to a single representative copy, and the turn ends gracefully with an `error` stop reason. Legitimate all-numeric/whitespace/punctuation runs (hexdumps, zero-fills, numeric tables) are not misclassified as loops ([#2549](https://github.com/can1357/oh-my-pi/pull/2549) by [@usr-bin-roygbiv](https://github.com/usr-bin-roygbiv)).
|
||||
- Added `pruneUseless` controls to `PruneConfig` and `SupersedePruneConfig` so callers can toggle compaction of `toolResult` entries marked `useless`
|
||||
- Added the ability to disable useless-result pruning by setting `pruneUseless` to false
|
||||
- Tools can flag a result contextually useless (`AgentToolResult.useless`; overridable via `AfterToolCallResult.useless`): the agent loop copies the flag onto the persisted `ToolResultMessage` (errors always win), and compaction consumes it — the cache-aware supersede pass and the threshold prune blank flagged results to the exact `USELESS_NOTICE` placeholder (bypassing the protect window, skipping results smaller than the notice), shake collects them inside the protect-recent window, and `serializeConversation` drops the whole tool call/result pair from summarizer input
|
||||
- Added `hasSteeringMessages` to `AgentLoopConfig` (wired by `Agent` to its steering queue): a peek used by the immediate-interrupt poll during tool execution, so the loop can detect queued steering without dequeuing and the queue keeps owning its messages until the injection boundary
|
||||
- The agent loop now re-samples after a non-terminal stop (`stopReason: "stop"` with `stopDetails: { type: "pause_turn" }`, emitted by the Codex providers for `end_turn: false` commentary-only responses): the assistant message is committed to history and the model is called again without ending the turn. Consecutive pause continuations without an intervening tool call are capped at 8 to bound a backend that never stops pausing.
|
||||
- `AgentTool.concurrency` now also accepts a per-call resolver function `(args) => "shared" | "exclusive"`, letting tools pick the scheduling mode from the call's arguments (a throwing resolver falls back to `"exclusive"`)
|
||||
- Added `convertMessageToLlm()`: the single-message core transformer behind `defaultConvertToLlm()`. Embedders with app-specific message roles should handle their own roles and delegate every core role (`user`/`developer`/`assistant`/`toolResult`/`custom`/`hookMessage`/`branchSummary`/`compactionSummary`) to it instead of duplicating the conversion — a duplicated `compactionSummary` case is how snapcompact frames once silently dropped off provider requests
|
||||
- Added `pruneSupersededToolResults()` and the opt-in `PruneConfig.supersedeKey` hook so harnesses can prune stale tool results superseded by a newer read of the same file; superseded results are pruned ahead of age-based victims during overflow pruning and replaced with a `[Superseded by a newer read of this file]` placeholder. Without the new config, `pruneToolOutputs()` behavior is unchanged.
|
||||
- Added `readToolSupersedeKey()` implementing the read-tool path/selector grammar (selector-free reads supersede range reads of the same file; URL-scheme paths exempt). Pruning honors prompt-cache economics: per-turn prunes only fire when the post-candidate suffix is small or the cache is cold (idle gap).
|
||||
- Added the `snapcompact` compaction strategy via `@oh-my-pi/snapcompact`: instead of an LLM summary, discarded history is printed onto dense bitmap frames and re-attached to the compaction summary message as image blocks. `CompactionSummaryMessage` gains an optional `images` field, `estimateTokens()` charges per attached frame, and frames persist under `preserveData.snapcompact` with an 8-frame middle-out eviction budget.
|
||||
- Snapcompact frames are now rendered in a provider-aware shape (`SNAPCOMPACT_SHAPES` + `resolveSnapcompactShape(api)`), following the snapcompact 200k-token monolithic evals: Anthropic-family and unknown APIs get `8x8r-bw` (unscii-8 square cells, black ink, every line printed twice with the copy on a pale highlight band — read at F1 parity with raw text at ~2x lower cost and the most refusal-robust), Google gets `8x8r-sent` (sentence-hue ink, ~2.9x cheaper), and OpenAI gets `6x6u-sent` (unscii Lanczos-stretched to 6x6 cells — OpenAI bills a flat ~2.9k tokens per image, so frame count is the only cost lever) with `detail: "original"` on the frame images. `snapcompactCompact()` accepts `model`/`shape` options, frames persist their shape metadata, mixed-shape archives (provider switches, legacy 5x8 frames) are flagged in the reading instructions, and `snapcompactGeometry()`/`renderSnapcompactFrame()` now take a shape
|
||||
- Added `AgentLoopConfig.getDisableReasoning` so callers can override `disableReasoning` per LLM call, mirroring `getReasoning`.
|
||||
- Added `transformProviderContext` to `AgentOptions`/`AgentLoopConfig`: an optional hook applied to the assembled provider context after conversion, normalization, and append-only handling, but before telemetry capture and provider send.
|
||||
- Added optional `fetch` overrides to `SummaryOptions` and `compact`/`generateSummary` so remote compaction can use custom HTTP clients
|
||||
- Added optional `fetch` option to `ProxyStreamOptions` to control the HTTP request used by `streamProxy`
|
||||
- Added optional `fetch` overrides to `requestOpenAiRemoteCompaction` and `requestRemoteCompaction` for injectable HTTP transport
|
||||
- Added the upstream provider that served a request (`AssistantMessage.upstreamProvider`, e.g. OpenRouter's routed provider) as a `pi.gen_ai.response.upstream_provider` chat-span telemetry attribute, alongside the existing response id and time-to-first-chunk.
|
||||
- Added a non-interrupting "aside" message channel to the agent loop (`AgentLoopConfig.getAsideMessages` / `Agent.setAsideMessageProvider`). Asides are drained at each step boundary (after a tool batch, before the next model call) and at the yield check, so passive notifications (e.g. background-job completions, late LSP diagnostics) reach the model *between requests* without waiting for the agent to stop and without aborting in-flight tools the way steering does.
|
||||
- Added optional `promptCacheKey` support to `AgentOptions` and `Agent` via a new `promptCacheKey` property so providers can receive a caller-provided prompt cache key
|
||||
- Added optional `ApiKeyResolveContext` parameter to `getApiKey` in `AgentOptions` and `AgentLoopConfig` so key resolvers can receive retry context
|
||||
- Added `getReadToolPath(context)` to `@oh-my-pi/pi-agent-core/compaction/tool-protection` to extract a paired `read` tool call's `path` for embedders building read-targeted protection matchers
|
||||
- Added `getReadToolPath(context)` to `@oh-my-pi/pi-agent-core/compaction/tool-protection`: the shared primitive that extracts a paired `read` tool call's `path` argument, so embedders can build their own read-targeted compaction protection matchers (e.g. plan-file reads) the same way `isSkillReadToolResult` does.
|
||||
- Added optional `AgentTool.matcherDigest(args)` hook: tools whose streamed arguments encode content in a wire grammar (patch formats, escaped strings) can expose the real content they introduce, so stream-content matchers (e.g. TTSR rules) run against plain source text instead of the wire format.
|
||||
- Added `shake` compaction primitives (`collectShakeRegions`, `applyShakeRegion`, `applyShakeRegions`, `summarizeShakeRegions`, `DEFAULT_SHAKE_CONFIG`, `AGGRESSIVE_SHAKE_CONFIG`, plus the `ShakeRegion`/`ShakeConfig`/`ShakeSummaryItem`/`ShakeSummaryComplete`/`ProtectedToolMatcher` types) under `@oh-my-pi/pi-agent-core/compaction`. These detect heavy context regions — whole tool-call results plus large fenced/XML blocks — and either elide them with placeholders or extractively compress them through an injected completion backend (no LLM summary cut-point). The compressor is provider-agnostic: callers wire it to a local on-device model. Pure detection/mutation; no I/O.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `pruneSupersededToolResults` to allow omitted `supersedeKey` when `pruneUseless` is enabled, so useless-result pruning can run without read-style supersede grouping
|
||||
- Changed steering handling so queued steering messages are now dequeued only at injection boundaries, with immediate mid-batch interrupt polling using `hasSteeringMessages`. Consumers constructing `AgentLoopConfig` directly with only `getSteeringMessages` no longer get mid-batch interrupts — steering degrades to boundary-only delivery until they also supply `hasSteeringMessages`
|
||||
- Compaction, handoff, short-summary, and branch-summarization helpers now accept an `ApiKey` (static string or resolver) instead of a pre-resolved string, so a 401 mid-compaction force-refreshes and rotates the credential through the central auth-retry policy before any model-level fallback. The remote OpenAI compaction request is wrapped in `withAuth` and its HTTP failures now carry `.status`, so the retry classifier actually fires on remote-compaction 401s.
|
||||
- `transformProviderContext` now receives the dispatch model as a second argument (`(context, model) => Context`), so per-request transforms can gate on model capabilities (vision input, provider, API family). Existing single-argument implementations keep working unchanged.
|
||||
- Remote-compaction and summarization failures now throw pi-ai's typed `ProviderHttpError` instead of mutating plain `Error`s with a `.status` property; the generic `requestRemoteCompaction` error now carries `.status` (and response headers) too.
|
||||
- Compaction and branch-summary file lists are now a single `<files>` tag instead of `<read-files>`/`<modified-files>`: paths render as the grouped, prefix-folded directory tree the find/search tools emit (`# dir/` headers, bare basenames), each annotated `(Read)`, `(Write)`, or `(RW)` — modified files that were also read get `(RW)`. Legacy tags in summaries written by earlier versions are still stripped and self-heal on the next compaction
|
||||
- Editorial pass over the compaction prompts: fixed garbled grammar and missing articles, RFC-keyed prohibitions, deduped restated instructions; parsed markers (`<read-files>`/`<modified-files>`/`<previous-summary>`) and all output-format headings left byte-identical
|
||||
- Catalog imports moved to the new `@oh-my-pi/pi-catalog` package: subpath imports (`calculateCost`, Codex wire constants) plus catalog values previously taken from the `@oh-my-pi/pi-ai` root (`getBundledModel`, `clampThinkingLevelForModel`), which pi-ai no longer re-exports; type-only `Model`/`Api`/`Effort` imports from pi-ai are unchanged
|
||||
- Changed core custom and hook messages to convert to `developer` messages for provider context.
|
||||
- Enabled streaming API calls to re-resolve credentials through the `getApiKey` callback when retries occur after authentication-related errors
|
||||
- `Agent.abort(reason?)` now forwards `reason` to the underlying `AbortController`, and the synthesized aborted assistant message carries that reason on `errorMessage` (string or non-`AbortError` `Error` message) instead of always defaulting to `"Request was aborted"`. Bare `abort()` is unchanged.
|
||||
- Changed `Agent.appendMessage`, `popMessage`, `clearMessages`, and `reset` to mutate `state.messages` and `state.pendingToolCalls` in place instead of allocating a fresh array/Set on every transition. Subscribers that capture `state.messages` by reference now observe updates without needing to re-read `state` after each event. The public type signature is unchanged (always `AgentMessage[]` / `Set<string>`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed repetition loop handling to collapse repeated `thinking` blocks to a single representative copy when a loop is detected
|
||||
- Fixed repetition-loop detection to ignore repeats that contain only digits, whitespace, or punctuation so legitimate numeric outputs no longer stop with a repetition-loop error
|
||||
- Fixed false-positive repetition-loop checks across `text` and `thinking` stream boundaries by tracking loop detection per block type
|
||||
|
||||
## [15.12.6] - 2026-06-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed dynamic forced tool choices from queue hooks being filtered against the active per-turn tool set before provider dispatch. ([#1701](https://github.com/can1357/oh-my-pi/issues/1701))
|
||||
|
||||
## [15.12.4] - 2026-06-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed remote compaction input trimming to use unlimited context when `model.contextWindow` is unset
|
||||
|
||||
## [15.12.1] - 2026-06-12
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed `pruneSupersededToolResults` to allow `supersedeKey` to be omitted so useless-result pruning can run without read-style supersede grouping
|
||||
|
||||
### Added
|
||||
|
||||
- Added `pruneUseless` controls to `PruneConfig` and `SupersedePruneConfig` so callers can toggle compaction of `toolResult` entries marked `useless`
|
||||
- Added the ability to disable useless-result pruning by setting `pruneUseless` to false
|
||||
- Tools can flag a result contextually useless (`AgentToolResult.useless`; overridable via `AfterToolCallResult.useless`): the agent loop copies the flag onto the persisted `ToolResultMessage` (errors always win), and compaction consumes it — the cache-aware supersede pass and the threshold prune blank flagged results to the exact `USELESS_NOTICE` placeholder (bypassing the protect window, skipping results smaller than the notice), shake collects them inside the protect-recent window, and `serializeConversation` drops the whole tool call/result pair from summarizer input
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `pruneSupersededToolResults` to allow omitted `supersedeKey` when `pruneUseless` is enabled, so useless-result pruning can run without read-style supersede grouping
|
||||
|
||||
## [15.11.4] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added `hasSteeringMessages` to `AgentLoopConfig` (wired by `Agent` to its steering queue): a peek used by the immediate-interrupt poll during tool execution, so the loop can detect queued steering without dequeuing and the queue keeps owning its messages until the injection boundary
|
||||
- The agent loop now re-samples after a non-terminal stop (`stopReason: "stop"` with `stopDetails: { type: "pause_turn" }`, emitted by the Codex providers for `end_turn: false` commentary-only responses): the assistant message is committed to history and the model is called again without ending the turn. Consecutive pause continuations without an intervening tool call are capped at 8 to bound a backend that never stops pausing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed steering handling so queued steering messages are now dequeued only at injection boundaries, with immediate mid-batch interrupt polling using `hasSteeringMessages`. Consumers constructing `AgentLoopConfig` directly with only `getSteeringMessages` no longer get mid-batch interrupts — steering degrades to boundary-only delivery until they also supply `hasSteeringMessages`
|
||||
- Compaction, handoff, short-summary, and branch-summarization helpers now accept an `ApiKey` (static string or resolver) instead of a pre-resolved string, so a 401 mid-compaction force-refreshes and rotates the credential through the central auth-retry policy before any model-level fallback. The remote OpenAI compaction request is wrapped in `withAuth` and its HTTP failures now carry `.status`, so the retry classifier actually fires on remote-compaction 401s.
|
||||
- `transformProviderContext` now receives the dispatch model as a second argument (`(context, model) => Context`), so per-request transforms can gate on model capabilities (vision input, provider, API family). Existing single-argument implementations keep working unchanged.
|
||||
- Remote-compaction and summarization failures now throw pi-ai's typed `ProviderHttpError` instead of mutating plain `Error`s with a `.status` property; the generic `requestRemoteCompaction` error now carries `.status` (and response headers) too.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a regression where steering messages could be injected into history during an aborted in-flight tool batch, leaving them hidden from queue consumers for post-abort continue
|
||||
|
||||
## [15.11.2] - 2026-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- `AgentTool.concurrency` now also accepts a per-call resolver function `(args) => "shared" | "exclusive"`, letting tools pick the scheduling mode from the call's arguments (a throwing resolver falls back to `"exclusive"`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed whitespace-only error tool results so Anthropic requests no longer 400 with `tool_result: content cannot be empty if is_error is true` and wedge the session on every subsequent turn
|
||||
|
||||
## [15.11.0] - 2026-06-10
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `compaction/index.ts` re-export of snapcompact helpers, so snapcompact utilities are no longer available from the agent compaction barrel and should be imported from `@oh-my-pi/snapcompact`
|
||||
- Removed the `convertToLlm` alias export from `compaction/messages` — it duplicated `defaultConvertToLlm` under a second name. Import `defaultConvertToLlm` (array form) or the new `convertMessageToLlm` (single-message form) instead
|
||||
|
||||
### Added
|
||||
|
||||
- Added `convertMessageToLlm()`: the single-message core transformer behind `defaultConvertToLlm()`. Embedders with app-specific message roles should handle their own roles and delegate every core role (`user`/`developer`/`assistant`/`toolResult`/`custom`/`hookMessage`/`branchSummary`/`compactionSummary`) to it instead of duplicating the conversion — a duplicated `compactionSummary` case is how snapcompact frames once silently dropped off provider requests
|
||||
- Added `pruneSupersededToolResults()` and the opt-in `PruneConfig.supersedeKey` hook so harnesses can prune stale tool results superseded by a newer read of the same file; superseded results are pruned ahead of age-based victims during overflow pruning and replaced with a `[Superseded by a newer read of this file]` placeholder. Without the new config, `pruneToolOutputs()` behavior is unchanged.
|
||||
- Added `readToolSupersedeKey()` implementing the read-tool path/selector grammar (selector-free reads supersede range reads of the same file; URL-scheme paths exempt). Pruning honors prompt-cache economics: per-turn prunes only fire when the post-candidate suffix is small or the cache is cold (idle gap).
|
||||
- Added the `snapcompact` compaction strategy via `@oh-my-pi/snapcompact`: instead of an LLM summary, discarded history is printed onto dense bitmap frames and re-attached to the compaction summary message as image blocks. `CompactionSummaryMessage` gains an optional `images` field, `estimateTokens()` charges per attached frame, and frames persist under `preserveData.snapcompact` with an 8-frame middle-out eviction budget.
|
||||
- Snapcompact frames are now rendered in a provider-aware shape (`SNAPCOMPACT_SHAPES` + `resolveSnapcompactShape(api)`), following the snapcompact 200k-token monolithic evals: Anthropic-family and unknown APIs get `8x8r-bw` (unscii-8 square cells, black ink, every line printed twice with the copy on a pale highlight band — read at F1 parity with raw text at ~2x lower cost and the most refusal-robust), Google gets `8x8r-sent` (sentence-hue ink, ~2.9x cheaper), and OpenAI gets `6x6u-sent` (unscii Lanczos-stretched to 6x6 cells — OpenAI bills a flat ~2.9k tokens per image, so frame count is the only cost lever) with `detail: "original"` on the frame images. `snapcompactCompact()` accepts `model`/`shape` options, frames persist their shape metadata, mixed-shape archives (provider switches, legacy 5x8 frames) are flagged in the reading instructions, and `snapcompactGeometry()`/`renderSnapcompactFrame()` now take a shape
|
||||
|
||||
### Changed
|
||||
|
||||
- Compaction and branch-summary file lists are now a single `<files>` tag instead of `<read-files>`/`<modified-files>`: paths render as the grouped, prefix-folded directory tree the find/search tools emit (`# dir/` headers, bare basenames), each annotated `(Read)`, `(Write)`, or `(RW)` — modified files that were also read get `(RW)`. Legacy tags in summaries written by earlier versions are still stripped and self-heal on the next compaction
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed queued steering messages being drained into an externally aborted run: interrupting mid-tool execution (e.g. Enter with a pending steer) dequeued the steer into the dying run — it landed in history without a response and the post-abort resume saw an empty queue, so the agent stopped instead of continuing. Steering/follow-up/aside queue polls are now skipped once the run's abort signal fires, leaving the queue intact for `Agent.continue()`.
|
||||
- Fixed `<read-files>` compaction lists recording the same file once per line-range/raw selector (`src/foo.ts:50-200`, `:raw`, `:1-50:raw`, …): read-tool selectors are now stripped before tracking, so reads dedupe to the base path and match their write/edit path when splitting read-only vs modified lists. Selector-polluted lists stored by earlier compactions self-heal on the next compaction. `readToolSupersedeKey()` now shares the same splitter (`splitReadSelector()`), gaining the `..` range alias and `L`-prefix forms it previously missed.
|
||||
- Fixed `estimateTokens()` undercounting thinking-heavy assistant messages on replay: `thinkingSignature` payloads (OpenAI Responses encrypted reasoning items, Anthropic signed thinking blocks, etc.) and `redactedThinking.data` are now charged alongside the visible thinking text, so the local estimate tracks provider-reported usage instead of straddling the threshold on every turn ([#2275](https://github.com/can1357/oh-my-pi/issues/2275)).
|
||||
|
||||
## [15.10.12] - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added `AgentLoopConfig.getDisableReasoning` so callers can override `disableReasoning` per LLM call, mirroring `getReasoning`.
|
||||
- Added `transformProviderContext` to `AgentOptions`/`AgentLoopConfig`: an optional hook applied to the assembled provider context after conversion, normalization, and append-only handling, but before telemetry capture and provider send.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `Agent` runs so explicit reasoning disablement is forwarded to provider stream options and re-resolved per continuation, keeping mid-run thinking-off changes in sync with the next provider request.
|
||||
|
||||
## [15.10.11] - 2026-06-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Editorial pass over the compaction prompts: fixed garbled grammar and missing articles, RFC-keyed prohibitions, deduped restated instructions; parsed markers (`<read-files>`/`<modified-files>`/`<previous-summary>`) and all output-format headings left byte-identical
|
||||
- Catalog imports moved to the new `@oh-my-pi/pi-catalog` package: subpath imports (`calculateCost`, Codex wire constants) plus catalog values previously taken from the `@oh-my-pi/pi-ai` root (`getBundledModel`, `clampThinkingLevelForModel`), which pi-ai no longer re-exports; type-only `Model`/`Api`/`Effort` imports from pi-ai are unchanged
|
||||
|
||||
## [15.10.8] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added optional `fetch` overrides to `SummaryOptions` and `compact`/`generateSummary` so remote compaction can use custom HTTP clients
|
||||
- Added optional `fetch` option to `ProxyStreamOptions` to control the HTTP request used by `streamProxy`
|
||||
- Added optional `fetch` overrides to `requestOpenAiRemoteCompaction` and `requestRemoteCompaction` for injectable HTTP transport
|
||||
- Added the upstream provider that served a request (`AssistantMessage.upstreamProvider`, e.g. OpenRouter's routed provider) as a `pi.gen_ai.response.upstream_provider` chat-span telemetry attribute, alongside the existing response id and time-to-first-chunk.
|
||||
|
||||
## [15.10.5] - 2026-06-08
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed stalled aborted assistant responses so the run now stops without waiting for provider iterator cleanup and returns the aborted message promptly
|
||||
- Fixed `afterToolCall` handling so it now runs for completed tool executions even after a run is aborted so tool post-processing still applies
|
||||
- Fixed `agentLoopDetailed().detailed()` so run telemetry and coverage are captured before `stream.result()` resolves.
|
||||
@@ -74,64 +140,96 @@
|
||||
- Fixed tool-call completion so assistant messages on abort keep only completed tool-call blocks and continue processing tool calls when a length stop still included results
|
||||
- Fixed deliberate aborts (TTSR rule matches, user-interrupt labels) so a mid-stream tool-call block that never reached `toolcall_end` is retained on the aborted assistant message and paired with a placeholder result labeled by the abort reason, instead of being dropped; anonymous aborts (bare `abort()`) still drop incomplete tool calls whose partial arguments are unsafe to replay
|
||||
- Fixed runs that stopped with reason `length` after returning tool results so execution continues to handle additional tool calls
|
||||
- Fixed the compaction spinner freezing (only repainting on a terminal resize) when compacting very large codex/OpenAI contexts. `buildOpenAiNativeHistory` re-collected the full known/custom tool-call id sets on every history-bearing message, rescanning the entire growing native history each time — O(N²) in history items — which blocked the event loop for seconds and starved the loader's animation timer and render scheduler. The sets are now maintained incrementally (linear), so building the compaction request no longer monopolizes the main thread.
|
||||
- Fixed proxy stream silently returning a zero-token success response when the server disconnects without sending a `done` or `error` terminal SSE event. The stream now throws an error, surfacing the disconnect as an `error` event with `stopReason: "error"` and resolving `finalResultPromise`, instead of defaulting to `stopReason: "stop"` with empty content and leaving `stream.result()` callers hanging indefinitely.
|
||||
- Fixed handling of short-lived API keys so that expired tokens are retried with a refreshed value during 401/usage-limit failures
|
||||
- Ensured fallback API key resolution uses the initially configured static `apiKey` when `getApiKey` is present
|
||||
- Wrapped oneshot LLM completions (`instrumentedCompleteSimple`: handoff, compaction/branch summaries) in an `EventLoopKeepalive`. These run outside the agent `#runLoop`, so without the keepalive Bun's event loop stopped servicing timers while parked on the completion promise — freezing host spinners (e.g. the `/handoff` loader) until an unrelated terminal resize poked the loop into rendering again.
|
||||
- Surfaced Anthropic stream failures whose message starts with `Output blocked by conten` as normal assistant error lifecycle events, so interactive clients render content-filter blocks instead of silently dropping the streaming bubble at `agent_end`.
|
||||
- Fixed the agent loop wedging the model when a `write`/`edit` tool call is truncated by `stop_reason: length` (e.g. an OpenCode Zen / Claude-3.5-Haiku turn that emits >~1000 lines of code, blowing past the 8K `max_tokens` output cap). The skipped tool result now surfaces an actionable hint — naming `stop_reason: length` and telling the model to split the payload into multiple smaller calls — instead of the generic "Tool call was not executed because the assistant ended its turn" placeholder, which left the auto-continue loop re-emitting the same oversized payload until the user gave up. Tools are still NOT executed when the arguments are truncated. ([#1785](https://github.com/can1357/oh-my-pi/issues/1785))
|
||||
- Engaged GPT-5 Harmony leak detection on the committed assistant message (openai-codex only). `detectHarmonyLeakInAssistantMessage` now runs on the streamed `done`/`error` result and the trailing fallback, so a leaked final response is aborted-and-retried by the existing mitigation instead of being committed as-is. Tool-argument (`tool_arg`) scanning is gated on the trailing-garbage `T` co-signal and only fires when a caller supplies a parse boundary via `detectHarmonyLeakInAssistantMessage`'s new optional `toolArgParseEnd` resolver. The agent loop passes none — it cannot bound a streamed tool DSL — so that surface stays inert and a legitimate codex tool call whose content legitimately carries `to=functions.*` next to a channel word or non-Latin script (e.g. editing the harmony fixtures) is never hard-aborted.
|
||||
- Fixed tool-output pruning and shake protection for `read`: ordinary file/URL reads are now eligible for compaction, while `read` calls whose `path` starts with `skill://` remain protected like native `skill` results.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls
|
||||
- Removed the now-dead `<turn-aborted>` marker from the OpenAI compaction output user-message filter, since `transformMessages` no longer emits that note.
|
||||
- Removed stale synthetic user-message tag filters from OpenAI remote compaction output preservation; developer messages are now dropped by role instead.
|
||||
- Tool executions now receive the active turn `AbortSignal` unconditionally.
|
||||
- Removed the local-model `summarizeShakeRegions` compressor and related shake-summary prompt/types; shake now only provides mechanical artifact-backed elision primitives.
|
||||
|
||||
## [15.13.0] - 2026-06-14
|
||||
|
||||
## [15.12.6] - 2026-06-14
|
||||
|
||||
## [15.12.4] - 2026-06-13
|
||||
|
||||
## [15.12.1] - 2026-06-12
|
||||
|
||||
## [15.11.4] - 2026-06-12
|
||||
|
||||
## [15.11.2] - 2026-06-11
|
||||
|
||||
## [15.11.0] - 2026-06-10
|
||||
|
||||
## [15.10.12] - 2026-06-10
|
||||
|
||||
## [15.10.11] - 2026-06-10
|
||||
|
||||
## [15.10.8] - 2026-06-09
|
||||
|
||||
## [15.10.5] - 2026-06-08
|
||||
|
||||
## [15.10.3] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added a non-interrupting "aside" message channel to the agent loop (`AgentLoopConfig.getAsideMessages` / `Agent.setAsideMessageProvider`). Asides are drained at each step boundary (after a tool batch, before the next model call) and at the yield check, so passive notifications (e.g. background-job completions, late LSP diagnostics) reach the model *between requests* without waiting for the agent to stop and without aborting in-flight tools the way steering does.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed core custom and hook messages to convert to `developer` messages for provider context.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the compaction spinner freezing (only repainting on a terminal resize) when compacting very large codex/OpenAI contexts. `buildOpenAiNativeHistory` re-collected the full known/custom tool-call id sets on every history-bearing message, rescanning the entire growing native history each time — O(N²) in history items — which blocked the event loop for seconds and starved the loader's animation timer and render scheduler. The sets are now maintained incrementally (linear), so building the compaction request no longer monopolizes the main thread.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the now-dead `<turn-aborted>` marker from the OpenAI compaction output user-message filter, since `transformMessages` no longer emits that note.
|
||||
- Removed stale synthetic user-message tag filters from OpenAI remote compaction output preservation; developer messages are now dropped by role instead.
|
||||
- Tool executions now receive the active turn `AbortSignal` unconditionally.
|
||||
|
||||
## [15.10.2] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed proxy stream silently returning a zero-token success response when the server disconnects without sending a `done` or `error` terminal SSE event. The stream now throws an error, surfacing the disconnect as an `error` event with `stopReason: "error"` and resolving `finalResultPromise`, instead of defaulting to `stopReason: "stop"` with empty content and leaving `stream.result()` callers hanging indefinitely.
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added optional `promptCacheKey` support to `AgentOptions` and `Agent` via a new `promptCacheKey` property so providers can receive a caller-provided prompt cache key
|
||||
- Added optional `ApiKeyResolveContext` parameter to `getApiKey` in `AgentOptions` and `AgentLoopConfig` so key resolvers can receive retry context
|
||||
|
||||
### Changed
|
||||
|
||||
- Enabled streaming API calls to re-resolve credentials through the `getApiKey` callback when retries occur after authentication-related errors
|
||||
- `Agent.abort(reason?)` now forwards `reason` to the underlying `AbortController`, and the synthesized aborted assistant message carries that reason on `errorMessage` (string or non-`AbortError` `Error` message) instead of always defaulting to `"Request was aborted"`. Bare `abort()` is unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed handling of short-lived API keys so that expired tokens are retried with a refreshed value during 401/usage-limit failures
|
||||
- Ensured fallback API key resolution uses the initially configured static `apiKey` when `getApiKey` is present
|
||||
- Wrapped oneshot LLM completions (`instrumentedCompleteSimple`: handoff, compaction/branch summaries) in an `EventLoopKeepalive`. These run outside the agent `#runLoop`, so without the keepalive Bun's event loop stopped servicing timers while parked on the completion promise — freezing host spinners (e.g. the `/handoff` loader) until an unrelated terminal resize poked the loop into rendering again.
|
||||
|
||||
## [15.9.5] - 2026-06-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Surfaced Anthropic stream failures whose message starts with `Output blocked by conten` as normal assistant error lifecycle events, so interactive clients render content-filter blocks instead of silently dropping the streaming bubble at `agent_end`.
|
||||
|
||||
## [15.8.3] - 2026-06-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added `getReadToolPath(context)` to `@oh-my-pi/pi-agent-core/compaction/tool-protection` to extract a paired `read` tool call's `path` for embedders building read-targeted protection matchers
|
||||
- Added `getReadToolPath(context)` to `@oh-my-pi/pi-agent-core/compaction/tool-protection`: the shared primitive that extracts a paired `read` tool call's `path` argument, so embedders can build their own read-targeted compaction protection matchers (e.g. plan-file reads) the same way `isSkillReadToolResult` does.
|
||||
|
||||
## [15.8.2] - 2026-06-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added optional `AgentTool.matcherDigest(args)` hook: tools whose streamed arguments encode content in a wire grammar (patch formats, escaped strings) can expose the real content they introduce, so stream-content matchers (e.g. TTSR rules) run against plain source text instead of the wire format.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the agent loop wedging the model when a `write`/`edit` tool call is truncated by `stop_reason: length` (e.g. an OpenCode Zen / Claude-3.5-Haiku turn that emits >~1000 lines of code, blowing past the 8K `max_tokens` output cap). The skipped tool result now surfaces an actionable hint — naming `stop_reason: length` and telling the model to split the payload into multiple smaller calls — instead of the generic "Tool call was not executed because the assistant ended its turn" placeholder, which left the auto-continue loop re-emitting the same oversized payload until the user gave up. Tools are still NOT executed when the arguments are truncated. ([#1785](https://github.com/can1357/oh-my-pi/issues/1785))
|
||||
|
||||
## [15.8.0] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Engaged GPT-5 Harmony leak detection on the committed assistant message (openai-codex only). `detectHarmonyLeakInAssistantMessage` now runs on the streamed `done`/`error` result and the trailing fallback, so a leaked final response is aborted-and-retried by the existing mitigation instead of being committed as-is. Tool-argument (`tool_arg`) scanning is gated on the trailing-garbage `T` co-signal and only fires when a caller supplies a parse boundary via `detectHarmonyLeakInAssistantMessage`'s new optional `toolArgParseEnd` resolver. The agent loop passes none — it cannot bound a streamed tool DSL — so that surface stays inert and a legitimate codex tool call whose content legitimately carries `to=functions.*` next to a channel word or non-Latin script (e.g. editing the harmony fixtures) is never hard-aborted.
|
||||
|
||||
## [15.7.4] - 2026-05-31
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the local-model `summarizeShakeRegions` compressor and related shake-summary prompt/types; shake now only provides mechanical artifact-backed elision primitives.
|
||||
|
||||
## [15.7.3] - 2026-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- Added `shake` compaction primitives (`collectShakeRegions`, `applyShakeRegion`, `applyShakeRegions`, `summarizeShakeRegions`, `DEFAULT_SHAKE_CONFIG`, `AGGRESSIVE_SHAKE_CONFIG`, plus the `ShakeRegion`/`ShakeConfig`/`ShakeSummaryItem`/`ShakeSummaryComplete`/`ProtectedToolMatcher` types) under `@oh-my-pi/pi-agent-core/compaction`. These detect heavy context regions — whole tool-call results plus large fenced/XML blocks — and either elide them with placeholders or extractively compress them through an injected completion backend (no LLM summary cut-point). The compressor is provider-agnostic: callers wire it to a local on-device model. Pure detection/mutation; no I/O.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tool-output pruning and shake protection for `read`: ordinary file/URL reads are now eligible for compaction, while `read` calls whose `path` starts with `skill://` remain protected like native `skill` results.
|
||||
|
||||
## [15.5.15] - 2026-05-30
|
||||
|
||||
### Added
|
||||
@@ -567,7 +665,7 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Switched from local `@oh-my-pi/pi-ai` to upstream `@oh-my-pi/pi-ai` package
|
||||
- Switched from local `@oh-my-pi/pi-ai` to upstream `@mariozechner/pi-ai` package
|
||||
|
||||
### Added
|
||||
|
||||
@@ -620,39 +718,65 @@
|
||||
|
||||
Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mono](https://github.com/badlogic/pi-mono).
|
||||
|
||||
## [0.38.0] - 2026-01-08
|
||||
|
||||
### Added
|
||||
|
||||
- `thinkingBudgets` option on `Agent` and `AgentOptions` to customize token budgets per thinking level ([#529](https://github.com/badlogic/pi-mono/pull/529) by [@melihmucuk](https://github.com/melihmucuk))
|
||||
|
||||
## [0.37.3] - 2026-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- `sessionId` option on `Agent` to forward session identifiers to LLM providers for session-based caching.
|
||||
|
||||
## [0.37.0] - 2026-01-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- `minimal` thinking level now maps to `minimal` reasoning effort instead of being treated as `low`.
|
||||
|
||||
## [0.32.0] - 2026-01-03
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Queue API replaced with steer/followUp**: The `queueMessage()` method has been split into two methods with different delivery semantics ([#403](https://github.com/badlogic/pi-mono/issues/403)):
|
||||
- `steer(msg)`: Interrupts the agent mid-run. Delivered after current tool execution, skips remaining tools.
|
||||
- `followUp(msg)`: Waits until the agent finishes. Delivered only when there are no more tool calls or steering messages.
|
||||
- **Queue mode renamed**: `queueMode` option renamed to `steeringMode`. Added new `followUpMode` option. Both control whether messages are delivered one-at-a-time or all at once.
|
||||
- **AgentLoopConfig callbacks renamed**: `getQueuedMessages` split into `getSteeringMessages` and `getFollowUpMessages`.
|
||||
- **Agent methods renamed**:
|
||||
- `queueMessage()` → `steer()` and `followUp()`
|
||||
- `clearMessageQueue()` → `clearSteeringQueue()`, `clearFollowUpQueue()`, `clearAllQueues()`
|
||||
- `setQueueMode()`/`getQueueMode()` → `setSteeringMode()`/`getSteeringMode()` and `setFollowUpMode()`/`getFollowUpMode()`
|
||||
|
||||
### Fixed
|
||||
|
||||
- `prompt()` and `continue()` now throw if called while the agent is already streaming, preventing race conditions and corrupted state. Use `steer()` or `followUp()` to queue messages during streaming, or `await` the previous call.
|
||||
|
||||
## [0.31.0] - 2026-01-02
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, and `AgentTransport` interface have been removed. Use the `streamFn` option directly for custom streaming implementations.
|
||||
|
||||
- **Agent options renamed**:
|
||||
- `transport` → removed (use `streamFn` instead)
|
||||
- `messageTransformer` → `convertToLlm`
|
||||
- `preprocessor` → `transformContext`
|
||||
|
||||
- **`AppMessage` renamed to `AgentMessage`**: All references to `AppMessage` have been renamed to `AgentMessage` for consistency.
|
||||
|
||||
- **`CustomMessages` renamed to `CustomAgentMessages`**: The declaration merging interface has been renamed.
|
||||
|
||||
- **`UserMessageWithAttachments` and `Attachment` types removed**: Attachment handling is now the responsibility of the `convertToLlm` function.
|
||||
|
||||
- **Agent loop moved from `@oh-my-pi/pi-ai`**: The `agentLoop`, `agentLoopContinue`, and related types have moved to this package. Import from `@oh-my-pi/pi-agent` instead.
|
||||
|
||||
### Added
|
||||
|
||||
- `streamFn` option on `Agent` for custom stream implementations. Default uses `streamSimple` from pi-ai.
|
||||
|
||||
- `streamProxy()` utility function for browser apps that need to proxy LLM calls through a backend server. Replaces the removed `AppTransport`.
|
||||
|
||||
- `getApiKey` option for dynamic API key resolution (useful for expiring OAuth tokens like GitHub Copilot).
|
||||
|
||||
- `agentLoop()` and `agentLoopContinue()` low-level functions for running the agent loop without the `Agent` class wrapper.
|
||||
|
||||
- New exported types: `AgentLoopConfig`, `AgentContext`, `AgentTool`, `AgentToolResult`, `AgentToolUpdateCallback`, `StreamFn`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Agent` constructor now has all options optional (empty options use defaults).
|
||||
|
||||
- `queueMessage()` is now synchronous (no longer returns a Promise).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -483,6 +483,7 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
|
||||
properties: {
|
||||
[INTENT_FIELD]: {
|
||||
type: "string",
|
||||
description: "Concise intent in present participle form (2-6 words) strictly on a single line, no newlines",
|
||||
},
|
||||
...properties,
|
||||
},
|
||||
|
||||
+294
-55
@@ -5,11 +5,17 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Responses, Azure OpenAI Responses, and Codex Responses providers ignoring async `onPayload` replacement bodies. Provider payload hooks can now transform the actual request body sent upstream, matching the Anthropic/Gemini replacement contract.
|
||||
## [15.13.1] - 2026-06-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the auth-broker (`OMP_AUTH_BROKER_URL`) rejecting OAuth credentials that carry provider-specific extension fields (e.g. an MCP server's `tokenUrl`/`clientId`/`clientSecret`/`resource` embedded for self-contained token refresh): the OAuth credential wire schema was `.strict()`, so `POST /v1/credential` failed with `400 unrecognized_keys` and a broker-backed MCP reauth reported success while the reloaded credential lacked its refresh material and could no longer refresh. The OAuth wire schema now uses `.loose()` to preserve unknown fields — matching the field-preserving local SQLite store — so extra OAuth fields round-trip through broker set->get (envelope and API-key schemas stay strict).
|
||||
|
||||
## [15.13.0] - 2026-06-14
|
||||
|
||||
### Fixed
|
||||
- Fixed OpenAI Responses/Realtime SSE stream handler crashing with "Error Code undefined: undefined" when parsing error events with nested error details by falling back to the nested error object fields.
|
||||
|
||||
- Fixed OpenAI Responses/Realtime SSE stream handler crashing with "Error Code undefined: undefined" when parsing error events with nested error details by falling back to the nested error object fields.
|
||||
- Fixed OpenAI-compatible providers that reject forced `tool_choice` on thinking-required models by downgrading unsupported forced choices to `auto` while keeping tools available ([#2546](https://github.com/can1357/oh-my-pi/issues/2546)).
|
||||
- Fixed GitHub Copilot Anthropic transport (`api.githubcopilot.com/v1/messages`) returning `400 tools.0.custom.eager_input_streaming: Extra inputs are not permitted` on every tool-bearing turn by stopping the emission of the per-tool `eager_input_streaming` flag and the `fine-grained-tool-streaming-2025-05-14` beta header on the Copilot transport — the proxy whitelists neither ([#2558](https://github.com/can1357/oh-my-pi/issues/2558)).
|
||||
- Disabled Bun's native ~300s pre-response `fetch` timeout in every streaming provider (OpenAI completions/responses, Azure responses, Anthropic, Codex SSE, Bedrock, Gemini CLI, Ollama). The configurable first-event/idle/SDK watchdogs (`PI_STREAM_FIRST_EVENT_TIMEOUT_MS`, `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS`, `compat.streamIdleTimeoutMs`) were silently capped by Bun's hidden ceiling, so cold large-context streams (e.g. self-hosted vLLM at multi-hundred-K prompts) died at exactly 300s with `TimeoutError: The operation timed out.` Direct callers of `./providers/{amazon-bedrock,google-gemini-cli,ollama,openai-codex-responses}` (which bypass `register-builtins`' iterator-level watchdog) now install a pre-response `AbortSignal.timeout(firstEventTimeoutMs)` alongside the disable, so a stalled upstream still fails within the configured budget instead of hanging forever ([#2422](https://github.com/can1357/oh-my-pi/issues/2422))
|
||||
@@ -304,6 +310,63 @@
|
||||
|
||||
- Removed the dead `iterateUntilAbort` helper (superseded by `iterateWithIdleTimeout`); it leaked the upstream iterator when the consumer abandoned mid-yield and had no production call sites.
|
||||
|
||||
## [15.10.10] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
- Exported `wrapFetchForCch` so non-streaming OAuth callers (e.g. the web-search provider) can patch the Claude Code billing-header `cch` attestation into their request bodies instead of shipping the `cch=00000` placeholder.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an unbounded, zero-backoff Codex WebSocket reconnect loop on `websocket_connection_limit_reached`: the no-content reconnect path never consulted the retry budget and never waited, hammering the endpoint forever when the limit is account-scoped. Reconnects are now budgeted and delayed like every other WS retry path, falling back to a single SSE replay when exhausted.
|
||||
- Fixed the Codex whitespace-loop breaker not observing degenerate frames that arrive after their item closed (or before it opened) — those frames count as stream progress, so the idle watchdogs never fired and the turn hung forever, which is exactly the failure mode the breaker exists for. Whitespace-loop recovery now also refuses to replay the turn once a `toolcall_end` was delivered, surfacing the error instead of re-emitting the same tool calls.
|
||||
- Fixed the two remaining Codex retry paths (WS mid-stream reconnect and the empty-content SSE fallback) leaking blockless native output items (e.g. `web_search_call`) from the failed attempt into the replayed turn's `providerPayload` and append baseline.
|
||||
- Fixed Codex WebSocket failure handling closing whatever connection currently occupies the session slot — including a concurrent caller's in-flight CONNECTING handshake, whose rejection (`websocket closed before open`) is classified fatal and disabled WebSockets for the whole session. Failure cleanup now skips CONNECTING sockets and the pool re-joins replacement handshakes (bounded).
|
||||
- Fixed the Codex request transformer not repairing orphan `custom_tool_call_output` items (only `function_call_output` was folded into an assistant note) — a compaction splice that dropped an `apply_patch` call while keeping its result produced a hard 400 on the default GPT-5 Codex toolset.
|
||||
- Fixed `processResponsesStream` finalizing reasoning items via a bare `itemId` content scan instead of the routed entry: with id-less reasoning items (local hosts), every `output_item.done` matched the FIRST thinking block — the second item's text clobbered it and the second block was never finalized or signed.
|
||||
- Fixed `processResponsesStream` dropping tool calls and message text whose `output_item.added` event was lost (lossy proxies): `toolcall_end` was emitted with a dangling contentIndex while the call never entered `message.content`, so the agent loop silently never executed it. The done handler now synthesizes the missing block; still-open tool-call blocks are also final-parsed at `response.completed` so the `toolUse` override cannot hand the agent stale `{}` arguments.
|
||||
- Fixed `response.incomplete` with `incomplete_details.reason: "content_filter"` being reported as a token-cap truncation (`stopReason: "length"`) — the agent loop's length recovery then asked the model to "shorten" a filtered prompt. Content-filtered turns now surface as errors; usage is also populated from `response.failed` events, and an unknown terminal status degrades to `"stop"` with a logged anomaly instead of throwing away a fully-streamed response.
|
||||
- Fixed Copilot `premiumRequests` accounting being dropped from failed/cancelled responses: `populateResponsesUsageFromResponse` replaced `usage` wholesale and the error path threw before the success-path re-apply. The populate now preserves the field.
|
||||
- Fixed `deduplicateToolCallIds` suffixing the whole composite Responses id (`callId|itemId`) — `normalizeResponsesToolCallId` extracts the first segment as the wire `call_id` at encode time, so both copies collapsed back onto one `call_id` and the request carried duplicate call/output pairs. The suffix and length budget now apply per segment.
|
||||
- Gated native history payload replay on api + model id in both Responses providers: after a mid-session model switch, reasoning items carrying encrypted content minted by the previous model were replayed verbatim under the new model. Replay now falls back to block re-encode (which already strips foreign signatures), matching `transformMessages`' same-model trust rule.
|
||||
- Fixed Azure OpenAI Responses requests omitting `store: false` while requesting `reasoning.encrypted_content` (stateless-only per OpenAI), replaying custom tool calls paired with mismatched `function_call_output` items (customCallIds was never threaded through), letting the SDK's internal retries (maxRetries 5) silently re-POST inside the explicit first-event deadline, and sending a `prompt_cache_key` when the caller opted out via `cacheRetention: "none"`.
|
||||
- Fixed strict-pairing Responses backends (Azure, Copilot) silently discarding tool results whose call is absent from history — the result is now folded into an assistant note (same shape as orphan-output repair) so the model keeps the information.
|
||||
- Fixed the OpenAI Responses first-event watchdog staying armed across the `onResponse` notification callback (a slow callback aborted an already-connected stream), Copilot transient-model retries re-attempting on an already-aborted signal (instant dead retry surfacing the scheduler's AbortError), Codex `reasoningSummary: null` being coerced to `"auto"` (the documented omit-summary contract was unreachable), nested Codex error codes (`response.error.code`) being invisible to the connection-limit/previous-response recovery matchers, and the session id leaking unredacted into `PI_CODEX_DEBUG` logs via the `x-client-request-id` header.
|
||||
- Fixed `processResponsesStream` (shared by `openai-responses` and `azure-openai-responses`) ignoring the terminal `response.incomplete` event: a max-output-tokens-truncated response ended with `stopReason: "stop"`, zero usage, and no cost instead of `"length"` with the reported token counts. `response.incomplete` is now handled alongside `response.completed` and counts as stream progress for the idle watchdogs.
|
||||
- Fixed custom tool-call content blocks keeping the transient `partialJson` accumulation buffer (and a potentially stale `arguments.input`) after `response.output_item.done` in the shared Responses stream processor — the function_call branch already cleaned these up.
|
||||
- Fixed two OpenAI Codex stream-retry paths (whitespace-loop recovery and retryable provider errors) leaking native output items from the abandoned attempt into the replayed turn's `providerPayload` — stale reasoning items completed before the failure were re-sent as history input on subsequent requests alongside the retry's own items.
|
||||
- Fixed the Codex WebSocket queue wiping already-received frames when a transport error arrived: a `response.completed` queued just before an eager server close was discarded, turning a finished response into a spurious `websocket closed` failure and a full request replay. Errors now append behind pending data frames.
|
||||
- Fixed concurrent `getOrCreateCodexWebSocketConnection` callers (prewarm racing the first request) tearing down each other's in-flight handshake — closing a CONNECTING socket rejected the other caller with a fatal `websocket closed before open`, disabling WebSockets for the entire session. Callers now join the pending handshake.
|
||||
- Stopped the Codex connection-limit recovery from replaying a turn over SSE after a `toolcall_end` had already been delivered to the consumer (`canSafelyReplayWebsocketOverSse` guard was bypassed, re-emitting the same tool calls); the error now surfaces instead.
|
||||
- Extended the Codex whitespace-only argument-delta circuit breaker to `custom_tool_call_input.delta` frames, which counted as stream progress and could keep a degenerate response alive forever with no cap on buffer growth.
|
||||
- Fixed Codex stream failures during transport open reporting a synthetic request dump (empty URL/body) instead of the real request, and a `response.created` event resetting the recorded time-to-first-token.
|
||||
- Fixed the Codex WebSocket connect watchdog timer leaking (pinning the event loop for up to 10s) when the request signal aborted before or during the handshake.
|
||||
- Fixed OpenRouter-hosted Anthropic adaptive reasoning models (Claude Fable/Mythos 5 and Opus 4.6+) so the catalog exposes `xhigh`; Fable/Mythos and Opus 4.7+ requests now map user `high`/`xhigh` onto OpenRouter's Anthropic `xhigh`/`max` effort scale.
|
||||
- Fixed an unknown Anthropic `stop_reason` failing the whole turn after the response had fully streamed. `mapStopReason` threw on unrecognized values, and since the reason arrives on the trailing `message_delta` the error was unretryable — the live `model_context_window_exceeded` stop reason (default on Sonnet 4.5+) hit this path. It now maps to `length`, and any future unknown reason degrades to a logged anomaly plus a normal `stop` instead of an error.
|
||||
- Stopped clamping API-key Anthropic requests to Claude Code's 64k output cap. The `CLAUDE_CODE_MAX_OUTPUT_TOKENS` clamp exists to match the OAuth wire fingerprint, but `buildParams` applied it unconditionally, silently halving the output budget of 128k-output models (e.g. Opus 4.8) for API-key callers. OAuth requests keep the clamp.
|
||||
- Stopped a successful strict-tools fallback from shipping `errorMessage` on a `stopReason: "stop"` assistant message. After a grammar-too-large 400 triggered the non-strict retry, the original 400 text was kept on the final message even when the retry succeeded — consumers that treat `errorMessage` presence as failure (e.g. balance probes) misclassified the turn, and the stale text suppressed later refusal explanations. The fallback is now logged instead.
|
||||
- Fixed model-supplied `User-Agent` headers being silently dropped on non-OAuth Anthropic requests. `enforcedHeaderKeys` filtered the header out of `modelHeaders` in every branch but only the OAuth branch set one back; the Cloudflare-gateway, bearer-gateway, and `X-Api-Key` branches now forward the caller's value verbatim.
|
||||
- Stopped sending the `fast-mode-2026-02-01` beta header once a session has learned the endpoint+model rejects fast mode (`fastModeDisabled` provider state), matching the already-dropped `speed` param.
|
||||
- Stopped `buildAnthropicHeaders` defaulting API-key requests onto the full Claude Code OAuth beta list (`oauth-2025-04-20`, `claude-code-20250219`, …). The `claudeCodeBetas` default is now OAuth-gated, matching the streaming path — the web-search header builder was the only caller hitting the default, so API-key search requests now carry just their own betas (e.g. `web-search-2025-03-05`). An empty `anthropic-beta` header is omitted entirely instead of being sent as an empty string.
|
||||
- Fixed image-bearing `developer` messages being upgraded to mid-conversation `system` turns on Opus 4.8+/Fable/Mythos 5. System content is text-only on the wire, so a developer turn carrying image blocks in an upgrade-eligible position produced a 400; it now stays a `user` message.
|
||||
- Fixed a spliced reconnect's second envelope overwriting the completed Anthropic message: `message_delta` was not gated by the terminal-stop flag (content events and duplicate `message_start` were), so the splice's `stop_reason`/usage replaced the finished turn's — a `tool_use` turn could be relabeled `stop`, and the harness then never executed the streamed tool calls. Post-terminal deltas are now logged as envelope anomalies and skipped.
|
||||
- Fixed a `ping` arriving before `message_start` consuming the Anthropic first-event watchdog: the stall was then classified as a terminal mid-stream idle timeout instead of a retryable first-event timeout. Pings no longer count as the first item but still refresh the idle deadline once content is flowing.
|
||||
- Fixed Anthropic-compatible proxies that omit `usage`/`delta` objects from `message_start`/`message_delta`/`content_block_*` envelopes crashing the turn with an unretryable `TypeError`; the missing payloads now degrade to logged envelope anomalies like every other malformed-frame case.
|
||||
- Fixed `applyPromptCaching` placing `cache_control` on `thinking`/`redacted_thinking` blocks — Anthropic rejects that with a 400. A thinking-only assistant turn inside the trailing cache window (e.g. followed by the synthetic `Continue.` pad) no longer receives a breakpoint.
|
||||
- Fixed consecutive `assistant` params reaching the wire when an empty user/developer turn between two assistant turns was dropped by the converter (e.g. an empty "nudge" submission after a length-truncated reply); Anthropic 400s on non-alternating assistant turns, and the broken triple replayed on every subsequent request. A `user: "Continue."` separator is now inserted, mirroring the trailing-prefill fallback.
|
||||
- Fixed `supportsAdaptiveThinkingDisplay` misparsing bare dated Opus ids: `claude-opus-4-20250514` (Opus 4.0) parsed as minor `20250514` ≥ 4.7, which silently dropped the `interleaved-thinking-2025-05-14` beta for API-key Opus 4.0 requests.
|
||||
- Fixed `output_config.effort` shipping without the `effort-2025-11-24` beta on thinking-off requests against adaptive-only Claude models (the effort:"low" pin), and the mid-conversation `system` role shipping without `mid-conversation-system-2026-04-07` on API-key and OAuth-utility requests; both betas are now added whenever the request can carry the corresponding field.
|
||||
- Fixed GitHub Copilot anthropic-messages requests going out with no `Content-Type` and no `anthropic-version` header — the copilot branch builds its headers from scratch and Bun's fetch does not default `Content-Type` for string bodies. Both headers are now pinned to match every other branch.
|
||||
- Fixed Anthropic client/provider retry multiplication: with the first-event watchdog disabled (`PI_STREAM_FIRST_EVENT_TIMEOUT_MS=0`), the client's internal `maxRetries: 5` reactivated and stacked with the provider loop's 3 retries — up to 24 wire attempts with double backoff. The provider now pins per-request `maxRetries: 0` unconditionally.
|
||||
- Fixed `AnthropicMessagesClient` spreading `fetchOptions` after the core request fields, letting a caller-supplied `signal`/`method`/`body` silently disconnect the timeout controller or corrupt the request. Transport extras (TLS) still pass through; core fields now always win.
|
||||
- Fixed Foundry mTLS/CA material being cached for the process lifetime when the env vars point at files: the cache key now folds in the file mtime so on-disk certificate rotation takes effect.
|
||||
- Fixed the Claude Code fingerprint version drifting across surfaces: the usage endpoint (`claude-cli/2.1.160`) and OAuth bootstrap (`claude-code/2.1.160`) pinned a stale version while `/v1/messages` reported 2.1.165; both now derive from `claudeCodeVersion`.
|
||||
- Fixed a system prompt that merely *mentions* `x-anthropic-billing-header:` mid-text suppressing the entire Claude Code system-block injection (billing header, instruction, and cch attestation); the resumed-session guard now anchors with `startsWith`.
|
||||
- Fixed lone surrogates in cross-API tool-call arguments reaching Anthropic's strict UTF-8 validation: replayed OpenAI/Google-origin `tool_use.input` string leaves are now deep-sanitized with `toWellFormed()`, while same-API Anthropic arguments stay byte-identical to keep prompt-cache prefixes stable.
|
||||
- Bounded the many-image resize fan-out to 4 concurrent decodes (it previously decoded every oversized image at once, two encode pipelines each — multi-GB transient memory at the 20+-image threshold that activates the feature).
|
||||
- Fixed `mergeHeaders` merging case-sensitively on the Copilot/client-options path, where a miscased user-configured header (e.g. `authorization` next to the synthesized `Authorization`) survived as two keys that the `Headers` constructor joins comma-separated on the wire.
|
||||
- Hardened the Anthropic stream lifecycle: prologue failures (e.g. a malformed Copilot credential in `buildCopilotDynamicHeaders`) and error-finalization failures now surface as an `error` event instead of an unhandled rejection that left `stream.result()` hanging forever; the spurious "cch billing placeholder not patched" warning no longer fires when the placeholder only appears in user content.
|
||||
|
||||
## [15.10.9] - 2026-06-09
|
||||
|
||||
### Added
|
||||
@@ -453,7 +516,6 @@
|
||||
- Fixed Antigravity usage provider emitting one bar per model instead of deduplicating by tier — a single account's 15+ model entries now collapse to one bar per tier, matching the shared-quota reality of the upstream API.
|
||||
- Fixed Antigravity usage reports missing `email` and `accountId` in metadata, so the `/usage` display and the deduplicator can associate reports with their credentials.
|
||||
- Fixed usage-report dedup ignoring `projectId` for Google Cloud providers, preventing duplicate credential entries from being recognized as the same account.
|
||||
|
||||
- Fixed Cloud Code Assist (Antigravity / Gemini CLI) rejecting the `github` tool with HTTP 400 when the `pr` parameter schema contained `anyOf: [string, array]`. The CCA mixed-type combiner collapse picked the first non-null type (`string`) but indiscriminately copied type-specific keys from variant branches — `items` from the array variant leaked onto the string-typed result, producing `{type: "string", items: {...}}` which Google's API rejects as invalid. The collapse now filters merged variant fields against the winning type's allowed key set. ([#2002](https://github.com/can1357/oh-my-pi/pull/2002))
|
||||
- Fixed OpenAI Responses-family providers (Codex, OpenAI Responses, Azure Responses) rejecting requests with `400 No tool output found for function call …` after the user branched/navigated the session tree to a node that ends on a tool call (the tool-result child is dropped from the reconstructed history) or after a turn was aborted/crashed between the call streaming and its result persisting. The converters now synthesize a placeholder `function_call_output`/`custom_tool_call_output` immediately after any unpaired `function_call`/`custom_tool_call`, symmetric to the existing orphan-output repair, so the model still sees the call and can recover instead of the whole request 400ing.
|
||||
- Fixed Anthropic-compatible reasoning endpoints losing prior-turn reasoning on continuation requests when they emit unsigned `thinking` blocks. `convertAnthropicMessages` treated unknown endpoints as signature-enforcing and demoted unsigned reasoning to `type: "text"`, which destabilized tool-call argument serialization on the next turn — the upstream symptom behind the `args?.ops?.map is not a function` crash reported against the `todo` tool. Official `api.anthropic.com` keeps the conservative text fallback; non-official `anthropic-messages` reasoning models now replay unsigned reasoning as native `type: "thinking"` ([#2005](https://github.com/can1357/oh-my-pi/issues/2005)).
|
||||
@@ -652,7 +714,6 @@
|
||||
### Added
|
||||
|
||||
- `SimpleStreamOptions.openrouterVariant` (`"nitro"`, `"floor"`, `"online"`, `"exacto"`, …) — when set, appends `:<variant>` to OpenRouter model IDs at request time, leaving ids that already carry an explicit `:suffix` untouched. Plumbed through `openai-completions` and the pi-native gateway forwarder.
|
||||
|
||||
- xAI Grok OAuth (SuperGrok Subscription) provider in `/login`. Loopback PKCE flow on `127.0.0.1:56121`; the token unlocks Grok-4.x chat. Ported from NousResearch/hermes-agent (MIT).
|
||||
- OpenRouter provider in `/login`. API-key paste flow validated against `https://openrouter.ai/api/v1/auth/key` (the `/models` endpoint is public and cannot validate auth). The pasted key is stored under the existing `openrouter` provider id used by `OPENROUTER_API_KEY`.
|
||||
- `XAI_OAUTH_TOKEN` environment variable accepted as a headless fallback for the xAI Grok OAuth provider.
|
||||
@@ -685,14 +746,11 @@
|
||||
- Added `PI_CODEX_WEBSOCKET_PING_INTERVAL_MS` to configure the interval for Codex WebSocket protocol ping heartbeats
|
||||
- Added `PI_CODEX_WEBSOCKET_PONG_TIMEOUT_MS` to configure the Codex WebSocket pong timeout used to detect unresponsive connections
|
||||
- Added `PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY` to configure the maximum buffered Codex WebSocket inbound queue size before transport fallback
|
||||
- Added `parseStreamingJsonThrottled` to `@oh-my-pi/pi-ai/utils/json-parse` — a per-delta wrapper around `parseStreamingJson` that skips re-parses until the buffer has grown by `minGrowthBytes` (default 256). Wired into the streaming hot path of every provider's tool-call argument accumulator (`anthropic`, `amazon-bedrock`, `openai-completions`, `openai-codex-responses`, `openai-responses-shared`) so per-delta cost is O(N) in total buffer length instead of O(N²). Each provider's `toolcall_end` still runs a final unthrottled parse, so the published `block.arguments` is unchanged.
|
||||
- Added named-tool routing support to Google providers: `GoogleSharedStreamOptions.toolChoice` and `GoogleGeminiCliOptions.toolChoice` now accept `{ mode: "ANY"; allowedFunctionNames: [string, ...string[]] }` in addition to the string forms. `mapGoogleToolChoice` converts `ToolChoice` objects of shape `{ type: "tool" | "function", name }` to the wire form. Mirrors the equivalent Anthropic mapper.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved Codex WebSocket timeout diagnostics to include last event type and time since last progress event
|
||||
- Enhanced Codex WebSocket error classification to recognize ping, pong, send, and queue-overflow failures as retryable
|
||||
- Changed `mapGoogleToolChoice` to be exported from `@oh-my-pi/pi-ai/stream` so callers can build the wire-shape allow-list directly without re-deriving it.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -701,7 +759,6 @@
|
||||
- Fixed Codex WebSocket pong timeout detection by tracking pong events and failing the connection when no pong is received within the configured timeout
|
||||
- Fixed Anthropic streaming to suppress hallucinated meta-prompt thinking blocks (the recent "I don't see any current rewritten thinking..." regression). When the marker phrase `rewritten thinking` appears in a streamed thinking summary the block is collapsed to a plain `Thinking...` placeholder and its signature is dropped so subsequent turns can't re-anchor on the garbled chain.
|
||||
- Fixed Codex WebSocket silent stalls by adding protocol pings, inbound queue bounding, clearer idle-timeout diagnostics, and SDK retry clamping for first-event timeouts.
|
||||
- Fixed Synthetic model discovery to treat the provider `/models` response as authoritative so deprecated bundled IDs are pruned from the runtime cache, and changed Synthetic login validation to avoid probing a specific model ([#1417](https://github.com/can1357/oh-my-pi/issues/1417)).
|
||||
|
||||
## [15.5.0] - 2026-05-26
|
||||
|
||||
@@ -825,10 +882,6 @@
|
||||
|
||||
- Added DeepSeek to the built-in API-key login provider catalog so `omp login deepseek` stores a reusable `DEEPSEEK_API_KEY` credential for the bundled DeepSeek models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `openai-responses` requests intermittently 400ing with `No tool call found for function call output with call_id …` after an aborted turn or a locally-rejected tool call (e.g. argument-validation failure). `convertConversationMessages` now folds orphan `function_call_output` / `custom_tool_call_output` items — those whose matching `function_call` was wiped by an earlier `dt: false` snapshot splice or never landed in any persisted provider payload — into assistant text notes, preserving the payload while keeping the request grammatically valid ([#1351](https://github.com/can1357/oh-my-pi/issues/1351)).
|
||||
|
||||
## [15.2.4] - 2026-05-22
|
||||
|
||||
### Fixed
|
||||
@@ -881,7 +934,6 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed Anthropic fast mode (`serviceTier: "priority"`) looping on 429 `rate_limit_error: "Extra usage is required for fast mode."` for accounts without the extra-usage entitlement. `isAnthropicFastModeUnsupportedError` now matches the 429 phrasing in addition to the 400 `invalid_request_error` "does not support the `speed` parameter" case, so the provider drops `speed: "fast"` on the in-turn retry, sets `providerSessionState.fastModeDisabled` for the remainder of the session, and surfaces `disabledFeatures: ["priority"]` to the caller instead of retrying with the same payload until `PROVIDER_MAX_RETRIES` is exhausted.
|
||||
- Fixed MiniMax Coding Plan CN streaming `<think>...</think>` reasoning as visible assistant text. The OpenAI-compatible stream parser now enables the existing MiniMax tag parser for both `minimax-code` and `minimax-code-cn`, so CN responses become structured `thinking` blocks instead of raw text. ([#1203](https://github.com/can1357/oh-my-pi/issues/1203))
|
||||
|
||||
## [15.1.6] - 2026-05-19
|
||||
|
||||
@@ -898,7 +950,6 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode-Go and OpenCode-Zen chat-completions replay to omit stored reasoning fields on Kimi assistant tool-call messages, avoiding provider 400s for rejected `messages[].reasoning` payloads. ([#1157](https://github.com/can1357/oh-my-pi/issues/1157))
|
||||
- Fixed OpenAI Responses and Codex tool schema normalization to emit `properties: {}` for no-argument object schemas without rewriting literal payloads. ([#1147](https://github.com/can1357/oh-my-pi/issues/1147))
|
||||
- Fixed Anthropic 400 (`unexpected tool_use_id found in tool_result blocks ... Each tool_result block must have a corresponding tool_use block in the previous message`) when handoff/compaction folds an assistant `tool_use` into the handoff summary string but leaves the matching user-side `tool_result` message in the history. `transformMessages` now indexes every `tool_use` id surviving the first pass and drops orphan `tool_result` messages whose originator was compacted away, preserving the text payload as a user-level `<stale-tool-result>` note so the model still sees what the tool returned. The note is emitted with `role: "user"` rather than `role: "developer"` so providers that elevate developer-role messages (Ollama: `developer` → `system`; OpenAI chat-completions reasoning models: `developer` → `developer`) cannot lift stale tool output to an instruction-priority tier above the surrounding user/developer messages.
|
||||
- Fixed streaming authentication retry to trigger when a provider emits a 401 `error` event after a `start` event but before any replay-unsafe content is emitted
|
||||
@@ -1087,11 +1138,6 @@
|
||||
|
||||
- Fixed OAuth credentials being silently disabled when two omp processes (or any two `AuthStorage` instances sharing a `agent.db`) race on token refresh. Anthropic rotates refresh tokens on every use, so the loser's `invalid_grant` response previously soft-deleted the row that the winner just rotated, forcing the user to `/login` again. `#tryOAuthCredential` now re-reads the row from disk before declaring a definitive failure: if the persisted `refresh` differs from the snapshot it tried, the peer-rotated credential is reloaded and the request retries against the fresh token instead of disabling the live row.
|
||||
- Closed a remaining race window in OAuth refresh-failure handling: between re-reading the credential row to check for peer rotation and the subsequent soft-delete, another process could still complete a refresh and rotate the row, leaving us to disable the freshly-rotated credential by `id`. The disable now runs as a single CAS update conditioned on the row's `data` still matching the snapshot we tried to refresh, and on `disabled_cause IS NULL`. If the CAS reports 0 rows changed (peer rotation, or row already disabled by a concurrent failure on the same snapshot), we reload from disk and retry instead of mutating the wrong row or emitting a spurious `credential_disabled` event.
|
||||
- Lazy built-in provider streams now enforce the shared idle watchdog and abort stalled provider requests, so session auto-retry can continue after transient network drops instead of remaining stuck. Caller aborts still terminate as aborted.
|
||||
|
||||
### Changed
|
||||
|
||||
- Lowered the default steady-state stream idle timeout from 120s to 30s while preserving the existing environment overrides.
|
||||
|
||||
## [14.9.3] - 2026-05-10
|
||||
|
||||
@@ -1104,7 +1150,6 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed silent forwarding of image content (for example Python plot output rendered in the terminal) to models without vision support, which produced opaque 404 errors from upstream. Image blocks are now stripped and replaced with a `[image omitted: model does not support vision]` placeholder for non-vision models, including tool-result payloads ([#967](https://github.com/can1357/oh-my-pi/issues/967), [#968](https://github.com/can1357/oh-my-pi/issues/968)).
|
||||
|
||||
- Added `AuthStorage` `onCredentialDisabled` callback (sync or async) so embedders can react when a credential is automatically disabled (e.g. OAuth refresh fails with `invalid_grant`) — useful for surfacing a banner or auto-launching a re-login flow instead of letting the credential silently disappear. Sync throws and async rejections are both caught and logged so a misbehaving subscriber cannot break the disable path.
|
||||
- Added Anthropic OAuth `account.uuid` and `account.email_address` extraction from the `/v1/oauth/token` exchange and refresh responses; both `AnthropicOAuthFlow.exchangeToken()` and `refreshAnthropicToken()` now populate `OAuthCredentials.{accountId, email}` so downstream consumers can attribute requests to the authenticated account without a separate `/api/oauth/profile` round-trip.
|
||||
- Added `onSseEvent` stream diagnostics so HTTP SSE providers can expose raw SSE frames without changing parsed model output.
|
||||
@@ -1122,7 +1167,6 @@
|
||||
|
||||
- Fixed Gemini 3 Pro thinking metadata so `medium` effort is rejected with the expected error instead of being silently accepted: `ThinkingConfig` now carries an optional explicit `levels` list that survives `expandEffortRange`, letting non-contiguous supported sets (e.g. `[low, high]`) round-trip through enrichment.
|
||||
- Fixed Kimi Code OAuth expiry handling to refresh access tokens 5 minutes before server expiry, avoiding daily 401s from using tokens right up to the cutoff.
|
||||
- Fixed OpenAI Responses custom tool replay to preserve custom tool call item IDs with the `ctc_` prefix instead of rewriting them as `fc_` function-call IDs ([#977](https://github.com/can1357/oh-my-pi/issues/977)).
|
||||
|
||||
## [14.7.6] - 2026-05-07
|
||||
|
||||
@@ -1386,7 +1430,6 @@
|
||||
- Fixed shell execution failure responses to preserve all result fields when sanitizing, preventing truncated metadata in stream results
|
||||
- Fixed context overflow detection to recognize `model_context_window_exceeded` from z.ai / GLM providers, preventing infinite retry loops when context window is exceeded ([#638](https://github.com/can1357/oh-my-pi/issues/638))
|
||||
- Fixed strict tool schema enforcement to preserve `additionalProperties: false` and required keys for reused nested object schemas, preventing invalid `todo_write` function schemas in Codex/OpenAI requests
|
||||
- Fixed GitHub Copilot reasoning regressions by preserving GPT-5.x / Claude 4.x reasoning controls instead of stripping them from requests ([#773](https://github.com/can1357/oh-my-pi/issues/773))
|
||||
|
||||
## [14.1.0] - 2026-04-11
|
||||
|
||||
@@ -1449,7 +1492,6 @@
|
||||
- Fixed Gemini 2.5 Pro context window detection in GitHub Copilot model limits test
|
||||
- Fixed Claude Opus 4.6 context window detection in GitHub Copilot model limits test
|
||||
- Fixed Anthropic streaming to suppress transient SDK console errors for malformed SSE keep-alive frames so the TUI only shows surfaced provider errors
|
||||
|
||||
- Added environment-based credential fallback for the OpenAI Codex provider.
|
||||
|
||||
## [13.17.6] - 2026-04-01
|
||||
@@ -1827,8 +1869,6 @@
|
||||
- Fixed OpenAI Codex streaming to properly include service_tier in SSE payloads
|
||||
- Fixed type safety in OpenAI responses by removing unsafe type casts on image content blocks
|
||||
- Fixed credential purging to respect disabled credentials when deduplicating by email
|
||||
- Fixed API-key provider re-login to replace the active stored key instead of appending stale credentials that were still selected first
|
||||
- Fixed Kagi login guidance to use the correct `KG_...` key format and mention Search API beta access requirements
|
||||
|
||||
## [13.9.2] - 2026-03-05
|
||||
|
||||
@@ -1853,7 +1893,7 @@
|
||||
- Removed `THINKING_LEVELS`, `ALL_THINKING_LEVELS`, `ALL_THINKING_MODES`, `THINKING_MODE_DESCRIPTIONS`, and `THINKING_MODE_LABELS` exports
|
||||
- Renamed `formatThinking()` to `getThinkingMetadata()` with changed return type from string to `ThinkingMetadata` object
|
||||
- Renamed `getAvailableThinkingLevel()` to `getAvailableThinkingLevels()` and added default parameter
|
||||
- Renamed `getAvailableEffort()` to `getAvailableEfforts()` and added default parameter
|
||||
- Renamed `getAvailableThinkingEffort()` to `getAvailableThinkingEfforts()` and added default parameter
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1863,17 +1903,17 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Exported new thinking module with `Effort`, `ThinkingLevel`, and `ThinkingMode` types for managing reasoning effort levels
|
||||
- Added `getAvailableEffort()` function to determine supported thinking effort levels based on model capabilities
|
||||
- Added `parseEffort()`, `parseThinkingLevel()`, and `parseThinkingMode()` functions for parsing thinking configuration strings
|
||||
- Exported new thinking module with `ThinkingEffort`, `ThinkingLevel`, and `ThinkingMode` types for managing reasoning effort levels
|
||||
- Added `getAvailableThinkingEffort()` function to determine supported thinking effort levels based on model capabilities
|
||||
- Added `parseThinkingEffort()`, `parseThinkingLevel()`, and `parseThinkingMode()` functions for parsing thinking configuration strings
|
||||
- Added `THINKING_LEVELS`, `ALL_THINKING_LEVELS`, and `ALL_THINKING_MODES` constants for iterating over available thinking options
|
||||
- Added `THINKING_MODE_DESCRIPTIONS` and `THINKING_MODE_LABELS` for displaying thinking modes in user interfaces
|
||||
- Added `formatThinking()` function to format thinking modes as compact display labels
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored thinking level handling to distinguish between `Effort` (provider-level, no "off") and `ThinkingLevel` (user-facing, includes "off")
|
||||
- Updated `ThinkingBudgets` type to use `Effort` instead of `ThinkingLevel` for more precise token budget configuration
|
||||
- Refactored thinking level handling to distinguish between `ThinkingEffort` (provider-level, no "off") and `ThinkingLevel` (user-facing, includes "off")
|
||||
- Updated `ThinkingBudgets` type to use `ThinkingEffort` instead of `ThinkingLevel` for more precise token budget configuration
|
||||
- Improved reasoning option handling to explicitly support "off" value for disabling reasoning across all providers
|
||||
- Simplified thinking effort mapping logic by centralizing provider-specific clamping behavior
|
||||
|
||||
@@ -2664,7 +2704,7 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced direct `Bun.env` access with `getEnv()` utility from `@oh-my-pi/pi-utils` for consistent environment variable handling across all providers
|
||||
- Replaced direct `process.env` access with `getEnv()` utility from `@oh-my-pi/pi-utils` for consistent environment variable handling across all providers
|
||||
- Updated environment variable names from `OMP_*` prefix to `PI_*` prefix for consistency (e.g., `OMP_CODING_AGENT_DIR` → `PI_CODING_AGENT_DIR`)
|
||||
|
||||
### Removed
|
||||
@@ -2691,13 +2731,13 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added `getEnv()` function to retrieve environment variables from Bun.env, cwd/.env, or ~/.env
|
||||
- Added `getEnv()` function to retrieve environment variables from process.env, cwd/.env, or ~/.env
|
||||
- Added support for reading .env files from home directory and current working directory
|
||||
- Added support for `exa` and `perplexity` as known providers in `getEnvApiKey()`
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `getEnvApiKey()` to check Bun.env, cwd/.env, and ~/.env files in order of precedence
|
||||
- Changed `getEnvApiKey()` to check process.env, cwd/.env, and ~/.env files in order of precedence
|
||||
- Refactored provider API key resolution to use a declarative service provider map
|
||||
|
||||
## [9.2.2] - 2026-01-31
|
||||
@@ -2903,7 +2943,7 @@
|
||||
- Replaced custom sleep implementations with Bun.sleep and abortableSleep
|
||||
- Simplified SSE stream parsing using readLines utility
|
||||
- Updated test framework from vitest to bun:test
|
||||
- Replaced temp directory creation with TempDir API
|
||||
- Replaced temp directory creation with createTempDirSync utility
|
||||
- Changed credential storage from auth.json to ~/.omp/agent/agent.db
|
||||
- Changed CLI command examples from npx to bunx
|
||||
- Refactored OAuth flows to use common callback server base class
|
||||
@@ -2946,8 +2986,8 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated environment variable prefix from PI* to OMP* for better consistency
|
||||
- Added automatic migration for legacy PI* environment variables to OMP* equivalents
|
||||
- Updated environment variable prefix from PI_ to OMP_ for better consistency
|
||||
- Added automatic migration for legacy PI_ environment variables to OMP_ equivalents
|
||||
- Adjusted Bedrock Claude thinking budgets to reserve output tokens when maxTokens is too low
|
||||
|
||||
### Fixed
|
||||
@@ -3064,7 +3104,7 @@
|
||||
|
||||
- Changed Cursor debug logging to use structured JSONL format with automatic MCP argument decoding
|
||||
- Changed MCP tool argument decoding to use protobuf Value schema for improved type handling
|
||||
- Changed tool advertisement to filter Cursor native tools (bash, read, write, delete, ls, grep, lsp) instead of only exposing mcp\_ prefixed tools
|
||||
- Changed tool advertisement to filter Cursor native tools (bash, read, write, delete, ls, grep, lsp) instead of only exposing mcp_ prefixed tools
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -3147,6 +3187,222 @@
|
||||
|
||||
- Enhanced error messages to include retry-after timing information from API rate limit headers
|
||||
|
||||
## [3.20.0] - 2026-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for kwaipilot/kat-coder-pro model via OpenRouter
|
||||
- Added OpenAI Codex responses provider with OAuth login support for ChatGPT Plus/Pro accounts
|
||||
- Added Google Vertex AI provider (Gemini via Vertex) with Application Default Credentials support
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated model specifications including context windows, max tokens, and pricing for multiple OpenRouter models
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed alibaba/tongyi-deepresearch-30b-a3b:free model from OpenRouter
|
||||
- Removed nousresearch/hermes-4-405b model from OpenRouter
|
||||
- Removed tngtech/tng-r1t-chimera:free model from OpenRouter
|
||||
|
||||
## [3.15.0] - 2026-01-05
|
||||
|
||||
### Changed
|
||||
|
||||
- Made `isError` field optional in `ToolResultMessage` interface, defaulting to non-error state
|
||||
|
||||
## [3.5.1337] - 2026-01-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added localhost URL detection for OpenAI-compatible provider auto-configuration
|
||||
|
||||
## [1.337.1] - 2026-01-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Forked to @oh-my-pi scope with unified versioning across all packages
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gemini CLI rate limit handling**: Added automatic retry with server-provided delay for 429 errors
|
||||
|
||||
## [1.337.0] - 2026-01-02
|
||||
|
||||
Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mono](https://github.com/badlogic/pi-mono).
|
||||
|
||||
## [0.50.1] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode Zen model generation to exclude deprecated models ([#970](https://github.com/badlogic/pi-mono/pull/970) by [@DanielTatarkin](https://github.com/DanielTatarkin))
|
||||
|
||||
## [0.50.0] - 2026-01-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenRouter provider routing support for custom models via `openRouterRouting` compat field ([#859](https://github.com/badlogic/pi-mono/pull/859) by [@v01dpr1mr0s3](https://github.com/v01dpr1mr0s3))
|
||||
- Added `azure-openai-responses` provider support for Azure OpenAI Responses API. ([#890](https://github.com/badlogic/pi-mono/pull/890) by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||
- Added HTTP proxy environment variable support for API requests ([#942](https://github.com/badlogic/pi-mono/pull/942) by [@haoqixu](https://github.com/haoqixu))
|
||||
- Added `createAssistantMessageEventStream()` factory function for use in extensions.
|
||||
- Added `resetApiProviders()` to clear and re-register built-in API providers.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored API streaming dispatch to use an API registry with provider-owned `streamSimple` mapping.
|
||||
- Moved environment API key resolution to `env-api-keys.ts` and re-exported it from the package entrypoint.
|
||||
- Azure OpenAI Responses provider now uses base URL configuration with deployment-aware model mapping and no longer includes service tier handling.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Bun runtime detection for dynamic imports in browser-compatible modules (stream.ts, openai-codex-responses.ts, openai-codex.ts) ([#922](https://github.com/badlogic/pi-mono/pull/922) by [@dannote](https://github.com/dannote))
|
||||
- Fixed streaming functions to use `model.api` instead of hardcoded API types
|
||||
- Fixed Google providers to default tool call arguments to an empty object when omitted
|
||||
- Fixed OpenAI Responses streaming to handle `arguments.done` events on OpenAI-compatible endpoints ([#917](https://github.com/badlogic/pi-mono/pull/917) by [@williballenthin](https://github.com/williballenthin))
|
||||
- Fixed OpenAI Codex Responses tool strictness handling after the shared responses refactor
|
||||
- Fixed Azure OpenAI Responses streaming to guard deltas before content parts and correct metadata and handoff gating
|
||||
- Fixed OpenAI completions tool-result image batching after consecutive tool results ([#902](https://github.com/badlogic/pi-mono/pull/902) by [@terrorobe](https://github.com/terrorobe))
|
||||
|
||||
## [0.49.3] - 2026-01-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added `headers` option to `StreamOptions` for custom HTTP headers in API requests. Supported by all providers except Amazon Bedrock (which uses AWS SDK auth). Headers are merged with provider defaults and `model.headers`, with `options.headers` taking precedence.
|
||||
- Added `originator` option to `loginOpenAICodex()` for custom OAuth client identification
|
||||
- Browser compatibility for pi-ai: replaced top-level Node.js imports with dynamic imports for browser environments ([#873](https://github.com/badlogic/pi-mono/issues/873))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Responses API 400 error "function_call without required reasoning item" when switching between models (same provider, different model). The fix omits the `id` field for function_calls from different models to avoid triggering OpenAI's reasoning/function_call pairing validation ([#886](https://github.com/badlogic/pi-mono/issues/886))
|
||||
|
||||
## [0.49.2] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added AWS credential detection for ECS/Kubernetes environments: `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`, `AWS_CONTAINER_CREDENTIALS_FULL_URI`, `AWS_WEB_IDENTITY_TOKEN_FILE` ([#848](https://github.com/badlogic/pi-mono/issues/848))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Responses 400 error "reasoning without following item" by skipping errored/aborted assistant messages entirely in transform-messages.ts ([#838](https://github.com/badlogic/pi-mono/pull/838))
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `strictResponsesPairing` compat option (no longer needed after the transform-messages fix)
|
||||
|
||||
## [0.49.1] - 2026-01-18
|
||||
|
||||
### Added
|
||||
|
||||
- Added `OpenAIResponsesCompat` interface with `strictResponsesPairing` option for Azure OpenAI Responses API, which requires strict reasoning/message pairing in history replay ([#768](https://github.com/badlogic/pi-mono/pull/768) by [@nicobako](https://github.com/nicobako))
|
||||
|
||||
### Changed
|
||||
|
||||
- Split `OpenAICompat` into `OpenAICompletionsCompat` and `OpenAIResponsesCompat` for type-safe API-specific compat settings
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tool call ID normalization for cross-provider handoffs (e.g., Codex to Antigravity Claude) ([#821](https://github.com/badlogic/pi-mono/issues/821))
|
||||
|
||||
## [0.49.0] - 2026-01-17
|
||||
|
||||
### Changed
|
||||
|
||||
- OpenAI Codex responses now use the context system prompt directly in the instructions field.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed orphaned tool results after errored assistant messages causing Codex API errors. When an assistant message has `stopReason: "error"`, its tool calls are now excluded from pending tool tracking, preventing synthetic tool results from being generated for calls that will be dropped by provider-specific converters. ([#812](https://github.com/badlogic/pi-mono/issues/812))
|
||||
- Fixed Bedrock Claude max_tokens handling to always exceed thinking budget tokens, preventing compaction failures. ([#797](https://github.com/badlogic/pi-mono/pull/797) by [@pjtf93](https://github.com/pjtf93))
|
||||
- Fixed Claude Code tool name normalization to match the Claude Code tool list case-insensitively and remove invalid mappings.
|
||||
|
||||
## [0.48.0] - 2026-01-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI-compatible provider feature detection to use `model.provider` in addition to URL, allowing custom base URLs (e.g., proxies) to work correctly with provider-specific settings ([#774](https://github.com/badlogic/pi-mono/issues/774))
|
||||
- Fixed Gemini 3 context loss when switching from providers without thought signatures: unsigned tool calls are now converted to text with anti-mimicry notes instead of being skipped
|
||||
- Fixed string numbers in tool arguments not being coerced to numbers during validation ([#786](https://github.com/badlogic/pi-mono/pull/786) by [@dannote](https://github.com/dannote))
|
||||
- Fixed Bedrock tool call IDs to use only alphanumeric characters, avoiding API errors from invalid characters ([#781](https://github.com/badlogic/pi-mono/pull/781) by [@pjtf93](https://github.com/pjtf93))
|
||||
- Fixed empty error assistant messages (from 429/500 errors) breaking the tool_use to tool_result chain by filtering them in `transformMessages`
|
||||
|
||||
## [0.47.0] - 2026-01-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode provider's `/v1` endpoint to use `system` role instead of `developer` role, fixing `400 Incorrect role information` error for models using `openai-completions` API ([#755](https://github.com/badlogic/pi-mono/pull/755) by [@melihmucuk](https://github.com/melihmucuk))
|
||||
- Added retry logic to OpenAI Codex provider for transient errors (429, 5xx, connection failures). Uses exponential backoff with up to 3 retries. ([#733](https://github.com/badlogic/pi-mono/issues/733))
|
||||
|
||||
## [0.46.0] - 2026-01-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added MiniMax China (`minimax-cn`) provider support ([#725](https://github.com/badlogic/pi-mono/pull/725) by [@tallshort](https://github.com/tallshort))
|
||||
- Added `gpt-5.2-codex` models for GitHub Copilot and OpenCode Zen providers ([#734](https://github.com/badlogic/pi-mono/pull/734) by [@aadishv](https://github.com/aadishv))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Avoid unsigned Gemini 3 tool calls ([#741](https://github.com/badlogic/pi-mono/pull/741) by [@roshanasingh4](https://github.com/roshanasingh4))
|
||||
- Fixed signature support for non-Anthropic models in Amazon Bedrock provider ([#727](https://github.com/badlogic/pi-mono/pull/727) by [@unexge](https://github.com/unexge))
|
||||
|
||||
## [0.45.7] - 2026-01-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Responses timeout option handling ([#706](https://github.com/badlogic/pi-mono/pull/706) by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||
- Fixed Bedrock tool call conversion to apply message transforms ([#707](https://github.com/badlogic/pi-mono/pull/707) by [@pjtf93](https://github.com/pjtf93))
|
||||
|
||||
## [0.45.6] - 2026-01-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Export `parseStreamingJson` from main package for tsx dev mode compatibility
|
||||
|
||||
## [0.45.4] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added Vercel AI Gateway provider with model discovery and `AI_GATEWAY_API_KEY` env support ([#689](https://github.com/badlogic/pi-mono/pull/689) by [@timolins](https://github.com/timolins))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed z.ai thinking/reasoning: z.ai uses `thinking: { type: "enabled" }` instead of OpenAI's `reasoning_effort`. Added `thinkingFormat` compat flag to handle this. ([#688](https://github.com/badlogic/pi-mono/issues/688))
|
||||
|
||||
## [0.45.0] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- MiniMax provider support with M2 and M2.1 models via Anthropic-compatible API ([#656](https://github.com/badlogic/pi-mono/pull/656) by [@dannote](https://github.com/dannote))
|
||||
- Add Amazon Bedrock provider with prompt caching for Claude models (experimental, tested with Anthropic Claude models only) ([#494](https://github.com/badlogic/pi-mono/pull/494) by [@unexge](https://github.com/unexge))
|
||||
- Added `serviceTier` option for OpenAI Responses requests ([#672](https://github.com/badlogic/pi-mono/pull/672) by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||
- **Anthropic caching on OpenRouter**: Interactions with Anthropic models via OpenRouter now set a 5-minute cache point using Anthropic-style `cache_control` breakpoints on the last assistant or user message. ([#584](https://github.com/badlogic/pi-mono/pull/584) by [@nathyong](https://github.com/nathyong))
|
||||
- **Google Gemini CLI provider improvements**: Added Antigravity endpoint fallback (tries daily sandbox then prod when `baseUrl` is unset), header-based retry delay parsing (`Retry-After`, `x-ratelimit-reset`, `x-ratelimit-reset-after`), stable `sessionId` derivation from first user message for cache affinity, empty SSE stream retry with backoff, and `anthropic-beta` header for Claude thinking models ([#670](https://github.com/badlogic/pi-mono/pull/670) by [@kim0](https://github.com/kim0))
|
||||
|
||||
## [0.43.0] - 2026-01-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Google provider thinking detection: `isThinkingPart()` now only checks `thought === true`, not `thoughtSignature`. Per Google docs, `thoughtSignature` is for context replay and can appear on any part type. Also removed `id` field from `functionCall`/`functionResponse` (rejected by Vertex AI and Cloud Code Assist), and added `textSignature` round-trip for multi-turn reasoning context. ([#631](https://github.com/badlogic/pi-mono/pull/631) by [@theBucky](https://github.com/theBucky))
|
||||
|
||||
## [0.42.3] - 2026-01-10
|
||||
|
||||
### Changed
|
||||
|
||||
- OpenAI Codex: switched to bundled system prompt matching opencode, changed originator to "pi", simplified prompt handling
|
||||
|
||||
## [0.42.2] - 2026-01-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added `GOOGLE_APPLICATION_CREDENTIALS` env var support for Vertex AI credential detection (standard for CI/production).
|
||||
- Added `supportsUsageInStreaming` compatibility flag for OpenAI-compatible providers that reject `stream_options: { include_usage: true }`. Defaults to `true`. Set to `false` in model config for providers like gatewayz.ai. ([#596](https://github.com/badlogic/pi-mono/pull/596) by [@XesGaDeus](https://github.com/XesGaDeus))
|
||||
- Improved Google model pricing info ([#588](https://github.com/badlogic/pi-mono/pull/588) by [@aadishv](https://github.com/aadishv))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `os.homedir()` calls at module load time; now resolved lazily when needed.
|
||||
- Fixed OpenAI Responses tool strict flag to use a boolean for LM Studio compatibility ([#598](https://github.com/badlogic/pi-mono/pull/598) by [@gnattu](https://github.com/gnattu))
|
||||
- Fixed Google Cloud Code Assist OAuth for paid subscriptions: properly handles long-running operations for project provisioning, supports `GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_PROJECT_ID` env vars for paid tiers, and handles VPC-SC affected users ([#582](https://github.com/badlogic/pi-mono/pull/582) by [@cmf](https://github.com/cmf))
|
||||
|
||||
## [0.42.0] - 2026-01-09
|
||||
|
||||
### Added
|
||||
@@ -3240,7 +3496,7 @@
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Agent API moved**: All agent functionality (`agentLoop`, `agentLoopContinue`, `AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, etc.) has moved to `@mariozechner/pi-agent-core`. Import from that package instead of `@oh-my-pi/pi-ai`.
|
||||
- **Agent API moved**: All agent functionality (`agentLoop`, `agentLoopContinue`, `AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, etc.) has moved to `@oh-my-pi/pi-agent-core`. Import from that package instead of `@oh-my-pi/pi-ai`.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -3256,7 +3512,6 @@
|
||||
### Fixed
|
||||
|
||||
- **OpenAI completions empty content blocks**: Empty text or thinking blocks in assistant messages are now filtered out before sending to the OpenAI completions API, preventing validation errors. ([#344](https://github.com/badlogic/pi-mono/pull/344) by [@default-anton](https://github.com/default-anton))
|
||||
- **Thinking token duplication**: Fixed thinking content duplication with chutes.ai provider. The provider was returning thinking content in both `reasoning_content` and `reasoning` fields, causing each chunk to be processed twice. Now only the first non-empty reasoning field is used.
|
||||
- **zAi provider API mapping**: Fixed zAi models to use `openai-completions` API with correct base URL (`https://api.z.ai/api/coding/paas/v4`) instead of incorrect Anthropic API mapping. ([#344](https://github.com/badlogic/pi-mono/pull/344), [#358](https://github.com/badlogic/pi-mono/pull/358) by [@default-anton](https://github.com/default-anton))
|
||||
|
||||
## [0.28.0] - 2025-12-25
|
||||
@@ -3286,11 +3541,8 @@
|
||||
### Fixed
|
||||
|
||||
- **Gemini multimodal tool results**: Fixed images in tool results causing flaky/broken responses with Gemini models. For Gemini 3, images are now nested inside `functionResponse.parts` per the [docs](https://ai.google.dev/gemini-api/docs/function-calling#multimodal). For older models (which don't support multimodal function responses), images are sent in a separate user message.
|
||||
|
||||
- **Queued message steering**: When `getQueuedMessages` is provided, the agent loop now checks for queued user messages after each tool call and skips remaining tool calls in the current assistant message when a queued message arrives (emitting error tool results).
|
||||
|
||||
- **Double API version path in Google provider URL**: Fixed Gemini API calls returning 404 after baseUrl support was added. The SDK was appending its default apiVersion to baseUrl which already included the version path. ([#251](https://github.com/badlogic/pi-mono/pull/251) by [@shellfyred](https://github.com/shellfyred))
|
||||
|
||||
- **Anthropic SDK retries disabled**: Re-enabled SDK-level retries (default 2) for transient HTTP failures. ([#252](https://github.com/badlogic/pi-mono/issues/252))
|
||||
|
||||
## [0.23.5] - 2025-12-19
|
||||
@@ -3298,17 +3550,13 @@
|
||||
### Added
|
||||
|
||||
- **Gemini 3 Flash thinking support**: Extended thinking level support for Gemini 3 Flash models (MINIMAL, LOW, MEDIUM, HIGH) to match Pro models' capabilities. ([#212](https://github.com/badlogic/pi-mono/pull/212) by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||
|
||||
- **GitHub Copilot thinking models**: Added thinking support for additional Copilot models (o3-mini, o1-mini, o1-preview). ([#234](https://github.com/badlogic/pi-mono/pull/234) by [@aadishv](https://github.com/aadishv))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gemini tool result format**: Fixed tool result format for Gemini 3 Flash Preview which strictly requires `{ output: value }` for success and `{ error: value }` for errors. Previous format using `{ result, isError }` was rejected by newer Gemini models. Also improved type safety by removing `as any` casts. ([#213](https://github.com/badlogic/pi-mono/issues/213), [#220](https://github.com/badlogic/pi-mono/pull/220))
|
||||
|
||||
- **Google baseUrl configuration**: Google provider now respects `baseUrl` configuration for custom endpoints or API proxies. ([#216](https://github.com/badlogic/pi-mono/issues/216), [#221](https://github.com/badlogic/pi-mono/pull/221) by [@theBucky](https://github.com/theBucky))
|
||||
|
||||
- **GitHub Copilot vision requests**: Added `Copilot-Vision-Request` header when sending images to GitHub Copilot models. ([#222](https://github.com/badlogic/pi-mono/issues/222))
|
||||
|
||||
- **GitHub Copilot X-Initiator header**: Fixed X-Initiator logic to check last message role instead of any message in history. This ensures proper billing when users send follow-up messages. ([#209](https://github.com/badlogic/pi-mono/issues/209))
|
||||
|
||||
## [0.22.3] - 2025-12-16
|
||||
@@ -3316,9 +3564,7 @@
|
||||
### Added
|
||||
|
||||
- **Image limits test suite**: Added comprehensive tests for provider-specific image limitations (max images, max size, max dimensions). Discovered actual limits: Anthropic (100 images, 5MB, 8000px), OpenAI (500 images, ≥25MB), Gemini (~2500 images, ≥40MB), Mistral (8 images, ~15MB), OpenRouter (~40 images context-limited, ~15MB). ([#120](https://github.com/badlogic/pi-mono/pull/120))
|
||||
|
||||
- **Tool result streaming**: Added `tool_execution_update` event and optional `onUpdate` callback to `AgentTool.execute()` for streaming tool output during execution. Tools can now emit partial results (e.g., bash stdout) that are forwarded to subscribers. ([#44](https://github.com/badlogic/pi-mono/issues/44))
|
||||
|
||||
- **X-Initiator header for GitHub Copilot**: Added X-Initiator header handling for GitHub Copilot provider to ensure correct call accounting (agent calls are not deducted from quota). Sets initiator based on last message role. ([#200](https://github.com/badlogic/pi-mono/pull/200) by [@kim0](https://github.com/kim0))
|
||||
|
||||
### Changed
|
||||
@@ -3352,9 +3598,7 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
### Fixed
|
||||
|
||||
- **GitHub Copilot gpt-5 models**: Fixed API selection for gpt-5 models to use `openai-responses` instead of `openai-completions` (gpt-5 models are not accessible via completions endpoint)
|
||||
|
||||
- **GitHub Copilot cross-model context handoff**: Fixed context handoff failing when switching between GitHub Copilot models using different APIs (e.g., gpt-5 to claude-sonnet-4). Tool call IDs from OpenAI Responses API were incompatible with other models. ([#198](https://github.com/badlogic/pi-mono/issues/198))
|
||||
|
||||
- **Gemini 3 Pro thinking levels**: Thinking level configuration now works correctly for Gemini 3 Pro models. Previously all levels mapped to -1 (minimal thinking). Now LOW/MEDIUM/HIGH properly control test-time computation. ([#176](https://github.com/badlogic/pi-mono/pull/176) by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||
|
||||
## [0.18.2] - 2025-12-11
|
||||
@@ -3372,9 +3616,7 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
### Fixed
|
||||
|
||||
- Fixed Mistral 400 errors after aborted assistant messages by skipping empty assistant messages (no content, no tool calls) ([#165](https://github.com/badlogic/pi-mono/issues/165))
|
||||
|
||||
- Removed synthetic assistant bridge message after tool results for Mistral (no longer required as of Dec 2025) ([#165](https://github.com/badlogic/pi-mono/issues/165))
|
||||
|
||||
- Fixed bug where `ANTHROPIC_API_KEY` environment variable was deleted globally after first OAuth token usage, causing subsequent prompts to fail ([#164](https://github.com/badlogic/pi-mono/pull/164))
|
||||
|
||||
## [0.17.0] - 2025-12-09
|
||||
@@ -3383,9 +3625,7 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
|
||||
- **`agentLoopContinue` function**: Continue an agent loop from existing context without adding a new user message. Validates that the last message is `user` or `toolResult`. Useful for retry after context overflow or resuming from manually-added tool results.
|
||||
- Added `validateToolCall(tools, toolCall)` helper that finds the tool by name and validates arguments.
|
||||
|
||||
- **OpenAI compatibility overrides**: Added `compat` field to `Model` for `openai-completions` API, allowing explicit configuration of provider quirks (`supportsStore`, `supportsDeveloperRole`, `supportsReasoningEffort`, `maxTokensField`). Falls back to URL-based detection if not set. Useful for LiteLLM, custom proxies, and other non-standard endpoints. ([#133](https://github.com/badlogic/pi-mono/issues/133), thanks @fink-andreas for the initial idea and PR)
|
||||
|
||||
- **xhigh reasoning level**: Added `xhigh` to `ReasoningEffort` type for OpenAI codex-max models. For non-OpenAI providers (Anthropic, Google), `xhigh` is automatically mapped to `high`. ([#143](https://github.com/badlogic/pi-mono/issues/143))
|
||||
|
||||
### Breaking Changes
|
||||
@@ -3411,7 +3651,6 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
### Fixed
|
||||
|
||||
- **OpenAI Token Counting**: Fixed `usage.input` to exclude cached tokens for OpenAI providers. Previously, `input` included cached tokens, causing double-counting when calculating total context size via `input + cacheRead`. Now `input` represents non-cached input tokens across all providers, making `input + output + cacheRead + cacheWrite` the correct formula for total context size.
|
||||
|
||||
- **Fixed Claude Opus 4.5 cache pricing** (was 3x too expensive)
|
||||
- Corrected cache_read: $1.50 → $0.50 per MTok
|
||||
- Corrected cache_write: $18.75 → $6.25 per MTok
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
* in `./types.ts` 1:1; the types remain the source of truth for static typing,
|
||||
* and `z.infer<typeof Schema>` is asserted-compatible with them where possible.
|
||||
*
|
||||
* Schemas use `.strict()` on objects with a closed set of fields so unknown
|
||||
* keys are rejected — the previous implementation used a hand-rolled
|
||||
* `hasOnlyFields` allowlist for the same effect.
|
||||
* Envelope and fixed-shape schemas use `.strict()` so unknown keys are
|
||||
* rejected — the previous implementation used a hand-rolled `hasOnlyFields`
|
||||
* allowlist for the same effect. The OAuth credential schema is the deliberate
|
||||
* exception (`.loose()`): it preserves provider-specific extension fields so
|
||||
* they round-trip through the broker instead of being dropped (see below).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { REMOTE_REFRESH_SENTINEL } from "../auth-storage";
|
||||
@@ -38,7 +40,15 @@ export const oauthCredentialSchema = z
|
||||
email: z.string().optional(),
|
||||
accountId: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
// `.loose()`, not `.strict()`: OAuth credentials carry an open set of
|
||||
// provider-specific extension fields beyond the base shape above — e.g. an
|
||||
// MCP server's tokenUrl/clientId/clientSecret/resource embedded so token
|
||||
// refresh works without an `auth` block in config. The storage layer
|
||||
// (`serializeCredential`/`deserializeCredential`/`exportSnapshot`) already
|
||||
// preserves unknown OAuth fields generically; the wire schema must match or
|
||||
// the broker set->get round-trip silently strips them and the credential
|
||||
// can no longer refresh after reload. Envelope schemas stay `.strict()`.
|
||||
.loose();
|
||||
|
||||
/** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */
|
||||
export const remoteOauthCredentialSchema = oauthCredentialSchema.extend({
|
||||
|
||||
@@ -450,7 +450,7 @@ export type AuthStorageOptions = {
|
||||
*
|
||||
* Examples:
|
||||
* - `"local ~/.omp/agent/agent.db"`
|
||||
* - `"broker http://can.internal:8765"`
|
||||
* - `"broker http://omp.internal:8765"`
|
||||
*/
|
||||
sourceLabel?: string;
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface ProviderDetailsContext {
|
||||
authMode?: string;
|
||||
/**
|
||||
* Human-readable description of the active credential, e.g.
|
||||
* `"broker http://can.internal:8765 · oauth #5 (foo@bar.com)"`.
|
||||
* `"broker http://omp.internal:8765 · oauth #5 (foo@bar.com)"`.
|
||||
* Rendered as a `Source` field; omitted when undefined.
|
||||
*/
|
||||
credentialSource?: string;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
AuthBrokerClient,
|
||||
type AuthBrokerServerHandle,
|
||||
AuthStorage,
|
||||
type OAuthCredential,
|
||||
REMOTE_REFRESH_SENTINEL,
|
||||
RemoteAuthCredentialStore,
|
||||
SqliteAuthCredentialStore,
|
||||
startAuthBroker,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
|
||||
// MCP OAuth credentials extend the base OAuthCredential with refresh material
|
||||
// (tokenUrl/clientId/clientSecret/resource) embedded so token refresh works for
|
||||
// configs that carry no `auth` block. These extension fields MUST survive the
|
||||
// broker upload + snapshot round-trip; the wire schemas previously dropped them
|
||||
// (`.strict()` rejected unknown keys), so a broker-backed reauth reported
|
||||
// success while the reloaded credential could no longer refresh.
|
||||
const MCP_PROVIDER = "mcp_oauth:profile:default:https://mcp.example.com/sse?project_ref=abc";
|
||||
const EXTRA_FIELDS = {
|
||||
tokenUrl: "https://mcp.example.com/oauth/token",
|
||||
clientId: "client-xyz",
|
||||
clientSecret: "client-secret-shhh",
|
||||
resource: "https://mcp.example.com/resource",
|
||||
} as const;
|
||||
|
||||
function mintMcpOAuthCredential(): OAuthCredential {
|
||||
const base: OAuthCredential = {
|
||||
type: "oauth",
|
||||
access: "access-mcp",
|
||||
refresh: "refresh-mcp",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
return { ...base, ...EXTRA_FIELDS };
|
||||
}
|
||||
|
||||
describe("auth-broker preserves extra OAuth credential fields", () => {
|
||||
let tempDir = "";
|
||||
let serverStore: SqliteAuthCredentialStore | undefined;
|
||||
let serverStorage: AuthStorage | undefined;
|
||||
let handle: AuthBrokerServerHandle | undefined;
|
||||
let remote: RemoteAuthCredentialStore | undefined;
|
||||
let clientStorage: AuthStorage | undefined;
|
||||
const token = "extra-fields-bearer";
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-broker-extra-fields-"));
|
||||
serverStore = await SqliteAuthCredentialStore.open(path.join(tempDir, "broker.db"));
|
||||
serverStorage = new AuthStorage(serverStore);
|
||||
await serverStorage.reload();
|
||||
handle = startAuthBroker({
|
||||
storage: serverStorage,
|
||||
bind: "127.0.0.1:0",
|
||||
bearerTokens: [token],
|
||||
disableRefresher: true,
|
||||
});
|
||||
// Long-poll (no SSE) keeps the round-trip deterministic for assertions.
|
||||
remote = new RemoteAuthCredentialStore({
|
||||
client: new AuthBrokerClient({ url: handle.url, token }),
|
||||
streamSnapshots: false,
|
||||
});
|
||||
clientStorage = new AuthStorage(remote);
|
||||
await clientStorage.reload();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
clientStorage?.close();
|
||||
await handle?.close();
|
||||
serverStorage?.close();
|
||||
serverStore?.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("broker set -> get round-trips tokenUrl/clientId/clientSecret/resource", async () => {
|
||||
await clientStorage!.set(MCP_PROVIDER, mintMcpOAuthCredential());
|
||||
|
||||
// Upload path (writableAuthCredentialSchema): the broker persisted the full
|
||||
// credential — the real refresh token plus every MCP extension field.
|
||||
const persisted = serverStore!.getOAuth(MCP_PROVIDER) as Record<string, unknown> | null;
|
||||
expect(persisted).not.toBeNull();
|
||||
expect(persisted).toMatchObject({ ...EXTRA_FIELDS, access: "access-mcp", refresh: "refresh-mcp" });
|
||||
|
||||
// Snapshot path (snapshotResponseSchema -> remoteOauthCredentialSchema): a
|
||||
// fresh client reads the extension fields back; only `refresh` is redacted
|
||||
// to the broker sentinel (the existing, intentional design).
|
||||
const snapshotResult = await new AuthBrokerClient({ url: handle!.url, token }).fetchSnapshot();
|
||||
if (snapshotResult.status !== 200) throw new Error("expected snapshot");
|
||||
const entry = snapshotResult.snapshot.credentials.find(candidate => candidate.provider === MCP_PROVIDER);
|
||||
expect(entry).toBeDefined();
|
||||
const credential = entry!.credential as Record<string, unknown>;
|
||||
expect(credential.type).toBe("oauth");
|
||||
expect(credential).toMatchObject(EXTRA_FIELDS);
|
||||
expect(credential.refresh).toBe(REMOTE_REFRESH_SENTINEL);
|
||||
|
||||
// Upload-response path (credentialUploadResponseSchema): the client's local
|
||||
// view from `set()` carries the extension fields too.
|
||||
const localView = remote!.listAuthCredentials(MCP_PROVIDER);
|
||||
expect(localView).toHaveLength(1);
|
||||
expect(localView[0].credential as unknown as Record<string, unknown>).toMatchObject(EXTRA_FIELDS);
|
||||
});
|
||||
});
|
||||
+122
-56
@@ -2,20 +2,142 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [15.13.1] - 2026-06-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added `modelFamilyToken(modelId)` to `@oh-my-pi/pi-catalog/identity`: a coarse vendor-lineage token (`anthropic`/`openai`/`gemini`/`kimi`/…) for "are two models the same family?" comparisons, backed by `parseKnownModel` canonical-id normalization. Opaque and comparison-only; kind/variant collapsed onto the vendor token ([#2406](https://github.com/can1357/oh-my-pi/issues/2406))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed catalog metadata to update a model’s per-token pricing to input 0.09 and output 0.18
|
||||
- Changed the same cataloged model’s maximum token limit from 384000 to 65536
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed MiniMax-M3 catalog context for `minimax` and `minimax-cn` to report the documented 1M long-context tier instead of the upstream 512K pricing boundary ([#2576](https://github.com/can1357/oh-my-pi/issues/2576)).
|
||||
- Fixed OpenCode Go MiMo catalog metadata so title generation and other tool-enabled calls omit unsupported `tool_choice` instead of triggering provider 400s ([#2509](https://github.com/can1357/oh-my-pi/issues/2509)).
|
||||
- Fixed OpenCode Go `kimi-k2.7-code` catalog metadata so resolve-gate requests use automatic tool selection instead of Moonshot-rejected forced `tool_choice` ([#2546](https://github.com/can1357/oh-my-pi/issues/2546)).
|
||||
- Fixed Anthropic compat for the `github-copilot` host so `supportsEagerToolInputStreaming` defaults to `false` there, matching the Copilot proxy which rejects the per-tool `eager_input_streaming` field ([#2558](https://github.com/can1357/oh-my-pi/issues/2558)).
|
||||
- Scoped vLLM model cache validity to the discovery base URL so changed endpoints refetch immediately, and bounded built-in vLLM discovery requests with a timeout.
|
||||
|
||||
## [15.12.6] - 2026-06-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added GLM-5.2 to the bundled zai (GLM Coding Plan) catalog as the selectable 1M served model.
|
||||
|
||||
### Changed
|
||||
|
||||
- Pinned zai `glm-5.2` to 1M context during catalog generation so endpoint discovery and older fallbacks cannot regress it to 200k.
|
||||
- Replaced the hand-maintained `zhipu-coding-plan` GLM reasoning allowlist and vision regex with a `parseGlmModel` family classifier in `identity/classify.ts` (variant + vision + version), surfaced as `isReasoningGlmModelId` / `isGlmVisionModelId`. Discovery now derives reasoning/vision capability from the GLM family instead of a per-id list, so newly-bumped integers (`glm-5.3`, `glm-6`, …) are covered automatically while `-flash`/`-preview` and the vision `…v` shape stay correctly classified.
|
||||
|
||||
## [15.12.4] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added bundled Fireworks models `deepseek-v4-flash`, `kimi-k2.7-code`, `minimax-m2.5`, `minimax-m3`, `nemotron-3-ultra-nvfp4`, `qwen3.6-plus`, and `qwen3.7-plus`
|
||||
- Changed
|
||||
|
||||
### Changed
|
||||
|
||||
- Model `contextWindow`/`maxTokens` are now `number | null`; discovery emits `null` when a provider reports no limit, replacing the `222222`/`8888` (`UNK_CONTEXT_WINDOW`/`UNK_MAX_TOKENS`) sentinels (now removed). Bundled `models.json` unknown limits are `null`.
|
||||
- Changed the `github-copilot` model context window to `524288` tokens
|
||||
- Changed Fireworks model discovery to source the control-plane `List Models` API (`GET /v1/accounts/fireworks/models?filter=supports_serverless=true`) instead of the OpenAI-compatible `/v1/models` inference listing. The inference endpoint returns a sparse, account-specific subset that omits on-demand serverless models (e.g. `kimi-k2.7-code`), so newly published serverless models stayed invisible in the picker until hand-added to the bundled catalog. The control-plane catalog enumerates every serverless model with capability metadata (`supportsServerless`/`supportsTools`/`supportsImageInput`/`contextLength`/`displayName`), paginated and filtered to tool-capable `READY` entries, then merged with bundled/models.dev references — the Kimi K2 max-output clamp and DeepSeek V4 thinking-toggle strip are preserved, and unbundled models default to reasoning so `buildModel` derives the Fireworks effort map. New serverless releases now surface automatically with no catalog edits.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Filled missing `contextWindow` and `maxTokens` in generated `models.json` for proxy/reseller variants by inheriting limits from canonical-family and segment-reference models
|
||||
- Ignored zero-cost `x-ai` subscription entries as reference sources when backfilling limits so inflated values are not propagated
|
||||
- Fixed the model cache opening with `PRAGMA journal_mode=WAL` before `PRAGMA busy_timeout`, so concurrent omp startups could crash inside `getDb()` on `SQLITE_BUSY` during WAL recovery instead of waiting through the transient lock. The busy handler is now installed before the first lock-taking statement ([#2421](https://github.com/can1357/oh-my-pi/issues/2421)).
|
||||
|
||||
## [15.11.8] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Antigravity `gemini-3.1-pro --thinking high` failing with `Cloud Code Assist API error (400): Request contains an invalid argument.` — the upstream `gemini-3.1-pro-high` deployment rejects every `streamGenerateContent` request on both CCA endpoints while discovery still advertises it. High effort now routes to `gemini-pro-agent` (the same "Gemini 3.1 Pro (High)" model, verified accepting the identical request body), and the model-cache fingerprint version was bumped (`merge-v2` → `merge-v3`) so existing fresh caches refetch discovery and pick up the corrected routing immediately.
|
||||
|
||||
## [15.11.7] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added effort-tier variant collapsing (`variant-collapse`): providers that expose one logical model as several effort/thinking-suffixed upstream ids (Antigravity CCA `gemini-3.5-flash-extra-low`/`-low`/`gemini-3-flash-agent`, `gemini-3[.1]-pro-low|high`, `claude-*[-thinking]` pairs, `gpt-oss-120b-medium`) collapse into one logical entry carrying per-effort upstream routing in `thinking.effortRouting` (plus `thinking.suppressWhenOff` for Cloud Code Assist ids whose baked server default re-applies when `thinkingConfig` is omitted). Request-time code resolves the outbound id via `resolveWireModelId(model, effort)`; selection, caching, and usage attribution key on the logical id.
|
||||
- Added the automatic `X`/`X-thinking` pair rule (`deriveThinkingPairFamilies`): any provider's live bare/thinking twin collapses into the bare id, routing thinking-enabled requests to the `-thinking` backing id (trailing or infix token, so `kimi-k2-thinking-turbo` pairs with `kimi-k2-turbo`). Gated on same api and compatible pricing — all-zero cost rows count as unknown, while twins that both carry real, differing prices remain separate SKUs.
|
||||
- Added `collapseBuiltModelVariants` and wired collapsing at every materialization point — Antigravity discovery, the catalog generator, and the model-manager merge — so stale sources (old static beside collapsed dynamic results, mixed cache rows) converge on logical entries instead of unioning raw tier ids back into the catalog.
|
||||
- Added `thinking.requiresEffort`, baked for reasoning-only upstreams — Gemini 3.x (levels only, no off), Gemini 2.5 Pro (thinkingBudget floors at 128, rejects 0), OpenAI o-series, MiniMax M2, and thinking-variant SKUs (`*-thinking`/`*-reasoner`/`*-reasoning`, with a negation-aware token grammar so `non-thinking` ids never match). Identity derivation bakes it for new entries and `fillThinkingWireDefaults` backfills explicit/cached metadata; `minimumSupportedEffort` exposes the canonical floor. Pair-collapsed twins drop member flags (their off routes to the bare SKU), while identity re-flags pairs whose logical id is itself mandatory
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed model display names to drop model-extrinsic decorations: gateway author prefixes (`OpenAI: …`, `Google: …`), `(latest)` alias markers, `(Antigravity)` provider attribution, price tiers (`($$$$)`), and promo/lifecycle tags (`(20% off)`, `(retires …)`). `cleanModelName` is applied in `buildModel` (covers live discovery and stale caches) and as a catalog-generator pass; Antigravity discovery no longer appends `(Antigravity)` to display names. Variant tags that map to distinct wire ids (`(Thinking)`, `(free)`, `(Fast)`, dates, regions) are preserved.
|
||||
- Changed the `google-antigravity` default model from `gemini-3-pro-high` to `gemini-3.1-pro`
|
||||
- Changed `gemini-2.5-flash-thinking` handling from discovery-denylist to collapsing into `gemini-2.5-flash` (thinking-enabled requests route to the `-thinking` backing id)
|
||||
- Bumped the model cache schema to v5 so rows predating effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids) are invalidated
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed catalog generation to apply effort-tier variant collapsing before provider grouping to ensure collapsed model families are consistently materialized without being impacted by in-loop mutation
|
||||
- Fixed Kimi K2.6 OpenAI-compatible compat metadata to use a 300s stream watchdog floor, covering Fire Pass router ids as well as public `kimi-k2.6` ids so long reasoning starts do not hit the generic first-event timeout ([#2366](https://github.com/can1357/oh-my-pi/issues/2366)).
|
||||
|
||||
## [15.11.4] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed MiniMax M2-family and OpenAI gpt-oss model metadata so OpenAI-compatible catalog entries declare only `low|medium|high` thinking efforts. Their upstreams reject `minimal`, `xhigh`, and Fireworks' `minimal → none` wire mapping, so `fireworks/minimax-m2.7` as the smol auto-thinking classifier model 400ed on every turn. OpenAI-compatible provider effort maps (`Groq qwen/qwen3-32b`, DeepSeek-family, OpenRouter Anthropic adaptive, Fireworks `minimal → none`) now bake into `thinking.effortMap` in catalog metadata instead of `buildOpenAICompat`, and request builders read that field directly. Regenerated `models.json` now makes `disableReasoning` choose `low` for those families while leaving GLM-5.x and other Fireworks models on the existing `minimal → none` path ([#2315](https://github.com/can1357/oh-my-pi/issues/2315)).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `requiresJuiceZeroHack` Responses-API compat flag, resolved by `buildOpenAIResponsesCompat` from GPT-5-family model names and overridable via sparse model `compat` config. Replaces the request-time `model.name.startsWith("gpt-5")` sniff that gated the trailing `# Juice: 0 !important` no-reasoning developer item.
|
||||
|
||||
## [15.11.3] - 2026-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- Added `requestModelId` on `Model` to represent the upstream model id used when a catalog entry is a local variant
|
||||
- Added synthetic GitHub Copilot long-context model variants with `-1m` suffixes when tiered token pricing is advertised
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed GitHub Copilot discovery to request `X-GitHub-Api-Version: 2026-06-01` from `api.githubcopilot.com`
|
||||
- Changed GitHub Copilot discovery to cap base model `contextWindow` to the default token tier and keep long-context access as the separate `-1m` model entry
|
||||
- Changed Copilot model mapping to omit non-chat `/models` entries and enable image input for models whose capabilities indicate vision support
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed long-context variant pricing to use `billing.token_prices.long_context` rates instead of default model pricing
|
||||
- Fixed `mapModel` handling in OpenAI-compatible discovery so returning `null` now skips a model entry rather than falling back to defaults
|
||||
- Fixed model ID precedence so a real upstream Copilot model id is kept when it conflicts with a synthesized `-1m` variant
|
||||
|
||||
## [15.11.1] - 2026-06-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed NVIDIA NIM Qwen turns failing with `400 Validation: Unsupported parameter(s): enable_thinking`. NIM's chat-completions schema is `additionalProperties: false` and exposes thinking via the vLLM convention `chat_template_kwargs.enable_thinking`; `buildOpenAICompat` was sending top-level `enable_thinking` for every `qwen/*` id regardless of host. Registered `nvidia` as a known host (`integrate.api.nvidia.com`) and routed NVIDIA-hosted Qwen models to `thinkingFormat: "qwen-chat-template"` ([#2299](https://github.com/can1357/oh-my-pi/issues/2299)).
|
||||
- Fixed Moonshot/Kimi native OpenAI-compatible request metadata so Kimi K2 uses `max_tokens` and omits OpenAI-only `store`, restoring first-turn output with `MOONSHOT_API_KEY` ([#2289](https://github.com/can1357/oh-my-pi/issues/2289)).
|
||||
|
||||
## [15.11.0] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `buildModel` so malformed explicit thinking metadata without `efforts` is treated as sparse input and inferred instead of crashing during model resolution ([#2251](https://github.com/can1357/oh-my-pi/issues/2251)).
|
||||
|
||||
## [15.10.12] - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added `grok-composer-2.5-fast` (Cursor "Composer 2.5 Fast") to the xAI Grok OAuth (SuperGrok) catalog: non-reasoning, text-only, 200K context.
|
||||
|
||||
### Changed
|
||||
|
||||
- Set every xAI Grok OAuth (SuperGrok) curated model's max output tokens to mirror its context window (`grok-build`, `grok-4.3`, `grok-4.20-0309-{reasoning,non-reasoning}`, `grok-4.20-multi-agent-0309`, `grok-composer-2.5-fast`), replacing the `8888` `UNK_MAX_TOKENS` placeholder (and a stale `30000` on three grok-4.x entries). xAI's OAuth `/v1/models` reports no per-request output limit, so the curated catalog now owns `maxTokens` like `contextWindow`, deterministic on both the static-seed and online-overlay paths; the `openai-responses` wire still clamps the actual request to `OPENAI_MAX_OUTPUT_TOKENS` (64k).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Excluded zero-cost `xai-oauth` subscription entries from the model reference indexes (`buildModelReferenceIndex`, `createReferenceResolver`), so their zero pricing and context-window-sized `maxTokens` cannot outrank paid/public Grok references when resolving custom-provider model identities.
|
||||
|
||||
## [15.10.11] - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added `hostMatchesUrl`, `modelMatchesHost`, and endpoint-shape helpers in the new `hosts` module for consistent provider/baseUrl matching
|
||||
- `buildModel(spec)` (`build.ts`) is now the single Model constructor: it materializes the fully-resolved compat record and canonical thinking metadata exactly once (compat first, thinking derived from identity + resolved compat), so `Model.compat` is a required, complete `CompatOf<TApi>` (`ResolvedOpenAICompat`/`ResolvedOpenAIResponsesCompat`/`ResolvedAnthropicCompat`) and request-path code reads fields with zero URL parsing and zero per-request allocation. Sparse user/config overrides live on the new `ModelSpec<TApi>` input shape and survive on `Model.compatConfig` for introspection.
|
||||
- Added `ResolvedAnthropicCompat.supportsSamplingParams` (Opus 4.7+/Fable/Mythos reject `temperature`/`top_p`/`top_k` with a 400), baked at build time from model identity so the request path stops re-parsing model ids.
|
||||
@@ -27,21 +149,6 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed catalog metadata to update a model’s per-token pricing to input 0.09 and output 0.18
|
||||
- Changed the same cataloged model’s maximum token limit from 384000 to 65536
|
||||
- Pinned zai `glm-5.2` to 1M context during catalog generation so endpoint discovery and older fallbacks cannot regress it to 200k.
|
||||
- Replaced the hand-maintained `zhipu-coding-plan` GLM reasoning allowlist and vision regex with a `parseGlmModel` family classifier in `identity/classify.ts` (variant + vision + version), surfaced as `isReasoningGlmModelId` / `isGlmVisionModelId`. Discovery now derives reasoning/vision capability from the GLM family instead of a per-id list, so newly-bumped integers (`glm-5.3`, `glm-6`, …) are covered automatically while `-flash`/`-preview` and the vision `…v` shape stay correctly classified.
|
||||
- Model `contextWindow`/`maxTokens` are now `number | null`; discovery emits `null` when a provider reports no limit, replacing the `222222`/`8888` (`UNK_CONTEXT_WINDOW`/`UNK_MAX_TOKENS`) sentinels (now removed). Bundled `models.json` unknown limits are `null`.
|
||||
- Changed the `github-copilot` model context window to `524288` tokens
|
||||
- Changed Fireworks model discovery to source the control-plane `List Models` API (`GET /v1/accounts/fireworks/models?filter=supports_serverless=true`) instead of the OpenAI-compatible `/v1/models` inference listing. The inference endpoint returns a sparse, account-specific subset that omits on-demand serverless models (e.g. `kimi-k2.7-code`), so newly published serverless models stayed invisible in the picker until hand-added to the bundled catalog. The control-plane catalog enumerates every serverless model with capability metadata (`supportsServerless`/`supportsTools`/`supportsImageInput`/`contextLength`/`displayName`), paginated and filtered to tool-capable `READY` entries, then merged with bundled/models.dev references — the Kimi K2 max-output clamp and DeepSeek V4 thinking-toggle strip are preserved, and unbundled models default to reasoning so `buildModel` derives the Fireworks effort map. New serverless releases now surface automatically with no catalog edits.
|
||||
- Changed model display names to drop model-extrinsic decorations: gateway author prefixes (`OpenAI: …`, `Google: …`), `(latest)` alias markers, `(Antigravity)` provider attribution, price tiers (`($$$$)`), and promo/lifecycle tags (`(20% off)`, `(retires …)`). `cleanModelName` is applied in `buildModel` (covers live discovery and stale caches) and as a catalog-generator pass; Antigravity discovery no longer appends `(Antigravity)` to display names. Variant tags that map to distinct wire ids (`(Thinking)`, `(free)`, `(Fast)`, dates, regions) are preserved.
|
||||
- Changed the `google-antigravity` default model from `gemini-3-pro-high` to `gemini-3.1-pro`
|
||||
- Changed `gemini-2.5-flash-thinking` handling from discovery-denylist to collapsing into `gemini-2.5-flash` (thinking-enabled requests route to the `-thinking` backing id)
|
||||
- Bumped the model cache schema to v5 so rows predating effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids) are invalidated
|
||||
- Changed GitHub Copilot discovery to request `X-GitHub-Api-Version: 2026-06-01` from `api.githubcopilot.com`
|
||||
- Changed GitHub Copilot discovery to cap base model `contextWindow` to the default token tier and keep long-context access as the separate `-1m` model entry
|
||||
- Changed Copilot model mapping to omit non-chat `/models` entries and enable image input for models whose capabilities indicate vision support
|
||||
- Set every xAI Grok OAuth (SuperGrok) curated model's max output tokens to mirror its context window (`grok-build`, `grok-4.3`, `grok-4.20-0309-{reasoning,non-reasoning}`, `grok-4.20-multi-agent-0309`, `grok-composer-2.5-fast`), replacing the `8888` `UNK_MAX_TOKENS` placeholder (and a stale `30000` on three grok-4.x entries). xAI's OAuth `/v1/models` reports no per-request output limit, so the curated catalog now owns `maxTokens` like `contextWindow`, deterministic on both the static-seed and online-overlay paths; the `openai-responses` wire still clamps the actual request to `OPENAI_MAX_OUTPUT_TOKENS` (64k).
|
||||
- Changed OpenAI compatibility detection to use shared host classifiers (`modelMatchesHost`/`hostMatchesUrl`) with normalized matching instead of raw URL substring checks
|
||||
- Changed `hostMatchesUrl`/`modelMatchesHost` usage in compatibility detection to reduce mismatches across case variants and provider alias hosts
|
||||
- Provider catalog entries now carry the runtime API-key env fallback as an ordered `envVars` list; `catalogDiscovery.envVars` became an optional generation-time override (only `cursor` and `vercel-ai-gateway` differ) and `PROVIDER_DESCRIPTORS` materializes the resolved list for `generate-models.ts`.
|
||||
@@ -52,25 +159,6 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed MiniMax-M3 catalog context for `minimax` and `minimax-cn` to report the documented 1M long-context tier instead of the upstream 512K pricing boundary ([#2576](https://github.com/can1357/oh-my-pi/issues/2576)).
|
||||
- Fixed OpenCode Go MiMo catalog metadata so title generation and other tool-enabled calls omit unsupported `tool_choice` instead of triggering provider 400s ([#2509](https://github.com/can1357/oh-my-pi/issues/2509)).
|
||||
- Fixed OpenCode Go `kimi-k2.7-code` catalog metadata so resolve-gate requests use automatic tool selection instead of Moonshot-rejected forced `tool_choice` ([#2546](https://github.com/can1357/oh-my-pi/issues/2546)).
|
||||
- Fixed Anthropic compat for the `github-copilot` host so `supportsEagerToolInputStreaming` defaults to `false` there, matching the Copilot proxy which rejects the per-tool `eager_input_streaming` field ([#2558](https://github.com/can1357/oh-my-pi/issues/2558)).
|
||||
- Scoped vLLM model cache validity to the discovery base URL so changed endpoints refetch immediately, and bounded built-in vLLM discovery requests with a timeout.
|
||||
- Filled missing `contextWindow` and `maxTokens` in generated `models.json` for proxy/reseller variants by inheriting limits from canonical-family and segment-reference models
|
||||
- Ignored zero-cost `x-ai` subscription entries as reference sources when backfilling limits so inflated values are not propagated
|
||||
- Fixed the model cache opening with `PRAGMA journal_mode=WAL` before `PRAGMA busy_timeout`, so concurrent omp startups could crash inside `getDb()` on `SQLITE_BUSY` during WAL recovery instead of waiting through the transient lock. The busy handler is now installed before the first lock-taking statement ([#2421](https://github.com/can1357/oh-my-pi/issues/2421)).
|
||||
- Fixed Antigravity `gemini-3.1-pro --thinking high` failing with `Cloud Code Assist API error (400): Request contains an invalid argument.` — the upstream `gemini-3.1-pro-high` deployment rejects every `streamGenerateContent` request on both CCA endpoints while discovery still advertises it. High effort now routes to `gemini-pro-agent` (the same "Gemini 3.1 Pro (High)" model, verified accepting the identical request body), and the model-cache fingerprint version was bumped (`merge-v2` → `merge-v3`) so existing fresh caches refetch discovery and pick up the corrected routing immediately.
|
||||
- Fixed catalog generation to apply effort-tier variant collapsing before provider grouping to ensure collapsed model families are consistently materialized without being impacted by in-loop mutation
|
||||
- Fixed Kimi K2.6 OpenAI-compatible compat metadata to use a 300s stream watchdog floor, covering Fire Pass router ids as well as public `kimi-k2.6` ids so long reasoning starts do not hit the generic first-event timeout ([#2366](https://github.com/can1357/oh-my-pi/issues/2366)).
|
||||
- Fixed MiniMax M2-family and OpenAI gpt-oss model metadata so OpenAI-compatible catalog entries declare only `low|medium|high` thinking efforts. Their upstreams reject `minimal`, `xhigh`, and Fireworks' `minimal → none` wire mapping, so `fireworks/minimax-m2.7` as the smol auto-thinking classifier model 400ed on every turn. OpenAI-compatible provider effort maps (`Groq qwen/qwen3-32b`, DeepSeek-family, OpenRouter Anthropic adaptive, Fireworks `minimal → none`) now bake into `thinking.effortMap` in catalog metadata instead of `buildOpenAICompat`, and request builders read that field directly. Regenerated `models.json` now makes `disableReasoning` choose `low` for those families while leaving GLM-5.x and other Fireworks models on the existing `minimal → none` path ([#2315](https://github.com/can1357/oh-my-pi/issues/2315)).
|
||||
- Fixed long-context variant pricing to use `billing.token_prices.long_context` rates instead of default model pricing
|
||||
- Fixed `mapModel` handling in OpenAI-compatible discovery so returning `null` now skips a model entry rather than falling back to defaults
|
||||
- Fixed model ID precedence so a real upstream Copilot model id is kept when it conflicts with a synthesized `-1m` variant
|
||||
- Fixed NVIDIA NIM Qwen turns failing with `400 Validation: Unsupported parameter(s): enable_thinking`. NIM's chat-completions schema is `additionalProperties: false` and exposes thinking via the vLLM convention `chat_template_kwargs.enable_thinking`; `buildOpenAICompat` was sending top-level `enable_thinking` for every `qwen/*` id regardless of host. Registered `nvidia` as a known host (`integrate.api.nvidia.com`) and routed NVIDIA-hosted Qwen models to `thinkingFormat: "qwen-chat-template"` ([#2299](https://github.com/can1357/oh-my-pi/issues/2299)).
|
||||
- Fixed Moonshot/Kimi native OpenAI-compatible request metadata so Kimi K2 uses `max_tokens` and omits OpenAI-only `store`, restoring first-turn output with `MOONSHOT_API_KEY` ([#2289](https://github.com/can1357/oh-my-pi/issues/2289)).
|
||||
- Fixed `buildModel` so malformed explicit thinking metadata without `efforts` is treated as sparse input and inferred instead of crashing during model resolution ([#2251](https://github.com/can1357/oh-my-pi/issues/2251)).
|
||||
- Excluded zero-cost `xai-oauth` subscription entries from the model reference indexes (`buildModelReferenceIndex`, `createReferenceResolver`), so their zero pricing and context-window-sized `maxTokens` cannot outrank paid/public Grok references when resolving custom-provider model identities.
|
||||
- Fixed Anthropic official-endpoint detection to require strict HTTPS hostname matching so non-official or lookalike URLs are no longer treated as official Anthropic hosts
|
||||
- Fixed Ollama Cloud dynamic discovery so same-id matches from other providers no longer supply context-window or max-output-token limits for discovered models.
|
||||
- Wired `@oh-my-pi/pi-catalog` into the release publish package list, tarball install smoke test, and root `bun generate-models` script.
|
||||
@@ -80,25 +168,3 @@
|
||||
### Removed
|
||||
|
||||
- Removed the runtime enrichment layer: `enrichModelThinking` (and its non-enumerable memo-slot cache), `refreshModelThinking`, `modelOmitsReasoningEffort`, and the `model-thinking` re-exports of generator-only policies. Thinking metadata is resolved exactly once inside `buildModel`; runtime helpers (`getSupportedEfforts`, `clampThinkingLevelForModel`, `requireSupportedEffort`, the effort mappers) are pure field reads.
|
||||
|
||||
## [15.13.0] - 2026-06-14
|
||||
|
||||
## [15.12.6] - 2026-06-14
|
||||
|
||||
## [15.12.4] - 2026-06-13
|
||||
|
||||
## [15.11.8] - 2026-06-12
|
||||
|
||||
## [15.11.7] - 2026-06-12
|
||||
|
||||
## [15.11.4] - 2026-06-12
|
||||
|
||||
## [15.11.3] - 2026-06-11
|
||||
|
||||
## [15.11.1] - 2026-06-11
|
||||
|
||||
## [15.11.0] - 2026-06-10
|
||||
|
||||
## [15.10.12] - 2026-06-10
|
||||
|
||||
## [15.10.11] - 2026-06-10
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
+1619
-578
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.13.0",
|
||||
"version": "15.13.1",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -9,11 +9,9 @@
|
||||
*/
|
||||
import { constants as fsConstants, type Stats } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import { SOURCE_PATHS } from "../discovery/helpers";
|
||||
|
||||
/** Provider id stamped on discovered managed skills (distinguishes them from authored). */
|
||||
export const MANAGED_SKILLS_PROVIDER_ID = "omp-managed";
|
||||
@@ -24,8 +22,8 @@ export const MAX_MANAGED_SKILL_BYTES = 64_000;
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
/** Resolve the isolated managed-skills directory (`~/.omp/agent/managed-skills`). */
|
||||
export function getManagedSkillsDir(home: string = os.homedir()): string {
|
||||
return path.join(home, SOURCE_PATHS.native.userAgent, "managed-skills");
|
||||
export function getManagedSkillsDir(agentDir: string = getAgentDir()): string {
|
||||
return path.join(agentDir, "managed-skills");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,13 +38,14 @@ export interface MCPServer {
|
||||
clientSecret?: string;
|
||||
resource?: string;
|
||||
};
|
||||
/** OAuth configuration (clientId, clientSecret, redirectUri, callbackPort, callbackPath) for servers requiring explicit client credentials */
|
||||
/** OAuth configuration (clientId, clientSecret, redirectUri, callbackPort, callbackPath, prompt) for servers requiring explicit client credentials */
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
redirectUri?: string;
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
/** Transport type */
|
||||
transport?: "stdio" | "sse" | "http";
|
||||
|
||||
@@ -15,8 +15,17 @@ try {
|
||||
* lightweight CLI runner from pi-utils.
|
||||
*/
|
||||
import type { CliConfig } from "@oh-my-pi/pi-utils/cli";
|
||||
import { APP_NAME, MIN_BUN_VERSION, VERSION } from "@oh-my-pi/pi-utils/dirs";
|
||||
import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/env";
|
||||
import {
|
||||
APP_NAME,
|
||||
getActiveProfile,
|
||||
MIN_BUN_VERSION,
|
||||
resolveProfileEnv,
|
||||
setProfile,
|
||||
VERSION,
|
||||
} from "@oh-my-pi/pi-utils/dirs";
|
||||
import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/worker-host";
|
||||
import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
|
||||
import { extractProfileFlags } from "./cli/profile-bootstrap";
|
||||
|
||||
if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
||||
process.stderr.write(
|
||||
@@ -27,11 +36,11 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
||||
|
||||
process.title = APP_NAME;
|
||||
|
||||
// Declare this module as the worker-host entry: Worker threads and worker
|
||||
// subprocesses re-enter `Bun.main` with a hidden argv selector instead of
|
||||
// loading separate worker entrypoints (single-entry contract across source,
|
||||
// npm bundle, and compiled binary).
|
||||
declareWorkerHostEntry();
|
||||
// Worker-host entry declaration (Worker threads and worker subprocesses
|
||||
// re-enter `Bun.main` with a hidden argv selector instead of loading separate
|
||||
// worker entrypoints) happens inside `runCli` after profile bootstrap:
|
||||
// `@oh-my-pi/pi-utils/env` eagerly loads `.env` from the agent directory at
|
||||
// import time, so it must not be imported before `setProfile` runs.
|
||||
|
||||
async function showHelp(config: CliConfig): Promise<void> {
|
||||
const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli");
|
||||
@@ -196,15 +205,67 @@ async function runTinyWorker(): Promise<void> {
|
||||
|
||||
/** Run the CLI with the given argv (no `process.argv` prefix). */
|
||||
export async function runCli(argv: string[]): Promise<void> {
|
||||
if (argv[0] === "--smoke-test") {
|
||||
await runSmokeTest();
|
||||
let resolvedArgv = argv;
|
||||
try {
|
||||
const extracted = extractProfileFlags(resolvedArgv);
|
||||
resolvedArgv = extracted.argv;
|
||||
if (extracted.profile !== undefined) {
|
||||
setProfile(extracted.profile);
|
||||
} else {
|
||||
// No explicit --profile: activate any OMP_PROFILE/PI_PROFILE inherited
|
||||
// from the environment. Module-load resolution deliberately swallows an
|
||||
// invalid value to avoid an uncaught throw before this try/catch is in
|
||||
// scope (see `readProfileFromEnvSafe` in dirs.ts), and callers may set
|
||||
// OMP_PROFILE after importing this module (profile aliases/tests). Surfacing
|
||||
// validation here turns `OMP_PROFILE=.. omp --version` into a clean error;
|
||||
// calling setProfile keeps every later path helper on the env-selected
|
||||
// profile instead of the default agent directory.
|
||||
setProfile(resolveProfileEnv(process.env.OMP_PROFILE, process.env.PI_PROFILE));
|
||||
}
|
||||
if (extracted.aliasName !== undefined) {
|
||||
const profile = extracted.profile ?? getActiveProfile();
|
||||
if (!profile) {
|
||||
throw new Error("--alias requires --profile <name> or OMP_PROFILE");
|
||||
}
|
||||
const result = await installProfileAlias({
|
||||
profile,
|
||||
aliasName: extracted.aliasName,
|
||||
command: resolveProfileAliasCommandFromProcess(),
|
||||
});
|
||||
process.stdout.write(
|
||||
`Created ${result.aliasName} for profile ${result.profile} in ${result.configPath}\n` +
|
||||
`Restart your shell or run: ${result.reloadedWith}\n` +
|
||||
`Then use: ${result.aliasName} update, ${result.aliasName} --version, or ${result.aliasName}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (TINY_WORKER_ARGS.has(argv[0] ?? "")) {
|
||||
|
||||
// Worker-thread entry dispatch must run before the first `await`: the
|
||||
// stats sync worker's buffering onmessage handler is installed in the
|
||||
// synchronous prefix of `runWorkerEntrypoint`, and Bun flushes the
|
||||
// worker's parked initial messages as soon as the entry module's
|
||||
// top-level evaluation finishes.
|
||||
if (TINY_WORKER_ARGS.has(resolvedArgv[0] ?? "")) {
|
||||
await runTinyWorker();
|
||||
return;
|
||||
}
|
||||
if (await runWorkerEntrypoint(argv[0])) {
|
||||
if (await runWorkerEntrypoint(resolvedArgv[0])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Declare this module as the worker-host entry now that the active profile
|
||||
// is resolved. The worker-host module is side-effect-free; importing
|
||||
// `@oh-my-pi/pi-utils/env` here would snapshot the wrong agent `.env`.
|
||||
declareWorkerHostEntry();
|
||||
|
||||
if (resolvedArgv[0] === "--smoke-test") {
|
||||
await runSmokeTest();
|
||||
return;
|
||||
}
|
||||
const [{ run }, { commands, resolveCliArgv }] = await Promise.all([
|
||||
@@ -213,7 +274,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
]);
|
||||
// --help and --version are handled by run() directly, don't rewrite those.
|
||||
// Everything else that isn't a known subcommand routes to "launch".
|
||||
const resolved = resolveCliArgv(argv);
|
||||
const resolved = resolveCliArgv(resolvedArgv);
|
||||
if ("error" in resolved) {
|
||||
process.stderr.write(`error: ${resolved.error}\n`);
|
||||
process.exitCode = 1;
|
||||
@@ -226,7 +287,13 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
// lowering) builds to fail, and the entrypoint needs nothing after this.
|
||||
// The catch mirrors what an unhandled TLA rejection produced: error dump to
|
||||
// stderr, exit code 1. Success paths resolve without touching the exit code.
|
||||
runCli(process.argv.slice(2)).catch((err: unknown) => {
|
||||
process.stderr.write(`${Bun.inspect(err, { colors: process.stderr.isTTY === true })}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
// Guarded so importing `runCli` (profile CLI tests, SDK embedding) does not
|
||||
// launch the agent as a side effect. Worker threads re-enter this module as
|
||||
// their entry with `import.meta.main === false`, so the worker-host dispatch
|
||||
// is admitted via `!Bun.isMainThread`.
|
||||
if (import.meta.main || !Bun.isMainThread) {
|
||||
runCli(process.argv.slice(2)).catch((err: unknown) => {
|
||||
process.stderr.write(`${Bun.inspect(err, { colors: process.stderr.isTTY === true })}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,12 +5,22 @@ import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort";
|
||||
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import { parseEffort } from "../thinking";
|
||||
import { BUILTIN_TOOLS } from "../tools";
|
||||
import { BUILTIN_TOOL_NAMES } from "../tools/builtin-names";
|
||||
import {
|
||||
OPTIONAL_FLAGS,
|
||||
OPTIONAL_VALUE_FLAGS,
|
||||
type ParseDeps,
|
||||
PROFILE_BOOTSTRAP_BOUNDARY_ARG,
|
||||
STRING_SETTERS,
|
||||
STRING_VALUE_FLAGS,
|
||||
} from "./flag-tables";
|
||||
|
||||
export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui";
|
||||
|
||||
export interface Args {
|
||||
cwd?: string;
|
||||
profile?: string;
|
||||
alias?: string;
|
||||
allowHome?: boolean;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
@@ -68,6 +78,19 @@ export interface Args {
|
||||
unrecognizedFlags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime dependencies the data-driven setters need. Constructed once at
|
||||
* module load and passed to every {@link STRING_SETTERS} call so the
|
||||
* setter table itself can stay free of `@oh-my-pi/pi-utils` runtime imports
|
||||
* (which would otherwise trip the profile bootstrap's env-init ordering).
|
||||
*/
|
||||
const PARSE_DEPS: ParseDeps = {
|
||||
logger,
|
||||
parseEffort,
|
||||
builtinToolNames: BUILTIN_TOOL_NAMES,
|
||||
thinkingEfforts: THINKING_EFFORTS,
|
||||
};
|
||||
|
||||
export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
|
||||
// Work on a copy: the `--option=value` handling below splices the value
|
||||
// into the array, and callers reuse the same argv (the post-extension
|
||||
@@ -81,19 +104,19 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
unrecognizedFlags: [],
|
||||
};
|
||||
|
||||
// `--` ends option parsing (POSIX end-of-options). Everything after it is
|
||||
// literal positional text, so flag-shaped messages are not parsed or rejected.
|
||||
let sawSeparator = false;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
let arg = args[i];
|
||||
const flagIndex = i;
|
||||
|
||||
// POSIX positional separator: once `--` lands, every remaining token is
|
||||
// a positional regardless of shape. Without this, a flag-looking message
|
||||
// (`omp -p -- --explain-this`) would be re-validated by the loop below
|
||||
// and rejected by the unknown-flag guard (#2461 review).
|
||||
if (sawSeparator) {
|
||||
result.messages.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === PROFILE_BOOTSTRAP_BOUNDARY_ARG) {
|
||||
continue;
|
||||
}
|
||||
const flagIndex = i;
|
||||
|
||||
// Support --flag=value syntax (e.g. --tools=ask,read). The value is
|
||||
// spliced in as the next token so value-consuming flags pick it up via
|
||||
@@ -120,112 +143,59 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
if (extFlag.type === "boolean") {
|
||||
result.unknownFlags.set(flagName, true);
|
||||
} else if (extFlag.type === "string" && i + 1 < args.length) {
|
||||
// Consume the value in `--flag=value` form, or when the next token is
|
||||
// not flag-looking. A `-`-prefixed token in space form is left to be
|
||||
// its own flag; pass a flag-looking value as `--flag=value`.
|
||||
// Consume the value in `--flag=value` form or when the next token is not
|
||||
// flag-looking. A standalone `--` remains the end-of-options marker; use
|
||||
// `--flag=--` when an extension needs a literal "--" string value.
|
||||
if (equalsValueIndex !== -1 || !args[i + 1].startsWith("-")) {
|
||||
result.unknownFlags.set(flagName, args[++i]);
|
||||
}
|
||||
}
|
||||
} else if (STRING_VALUE_FLAGS.has(arg)) {
|
||||
// Built-in string flags consume the next token even when it is flag-looking
|
||||
// (`--system-prompt --profile foo` ⇒ the prompt is the literal "--profile").
|
||||
// The one token they must never absorb is the profile bootstrap's internal
|
||||
// boundary sentinel: an extension-shadowable built-in like `--plan` (parsed
|
||||
// here only when its boolean extension is NOT loaded) would otherwise swallow
|
||||
// the marker as its value and drop the user's trailing message.
|
||||
if (i + 1 < args.length && args[i + 1] !== PROFILE_BOOTSTRAP_BOUNDARY_ARG) {
|
||||
STRING_SETTERS[arg](result, args[++i], PARSE_DEPS);
|
||||
}
|
||||
} else if (OPTIONAL_VALUE_FLAGS.has(arg)) {
|
||||
const config = OPTIONAL_FLAGS[arg];
|
||||
const next = args[i + 1];
|
||||
const consume =
|
||||
next !== undefined && !next.startsWith("-") && !(config.rejectEmpty === true && next.length === 0);
|
||||
config.set(result, consume ? args[++i] : undefined);
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
} else if (arg === "--version" || arg === "-v") {
|
||||
result.version = true;
|
||||
} else if (arg === "--allow-home") {
|
||||
result.allowHome = true;
|
||||
} else if (arg === "--cwd" && i + 1 < args.length) {
|
||||
result.cwd = args[++i];
|
||||
} else if (arg === "--config" && i + 1 < args.length) {
|
||||
result.config = [...(result.config ?? []), args[++i]];
|
||||
} else if (arg === "--mode" && i + 1 < args.length) {
|
||||
const mode = args[++i];
|
||||
if (mode === "text" || mode === "json" || mode === "rpc" || mode === "acp" || mode === "rpc-ui") {
|
||||
result.mode = mode;
|
||||
}
|
||||
} else if (arg === "--profile" && i + 1 < args.length) {
|
||||
// Normally stripped by `extractProfileFlags` before parseArgs sees it;
|
||||
// kept here as a fallback for direct parseArgs callers.
|
||||
result.profile = args[++i];
|
||||
} else if (arg.startsWith("--profile=")) {
|
||||
result.profile = arg.slice("--profile=".length);
|
||||
} else if (arg === "--alias" && i + 1 < args.length) {
|
||||
result.alias = args[++i];
|
||||
} else if (arg.startsWith("--alias=")) {
|
||||
result.alias = arg.slice("--alias=".length);
|
||||
} else if (arg === "--continue" || arg === "-c") {
|
||||
result.continue = true;
|
||||
} else if (arg === "--resume" || arg === "-r" || arg === "--session") {
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("-")) {
|
||||
result.resume = args[++i];
|
||||
} else {
|
||||
result.resume = true;
|
||||
}
|
||||
} else if (arg === "--fork" && i + 1 < args.length) {
|
||||
result.fork = args[++i];
|
||||
} else if (arg === "--provider" && i + 1 < args.length) {
|
||||
result.provider = args[++i];
|
||||
} else if (arg === "--model" && i + 1 < args.length) {
|
||||
result.model = args[++i];
|
||||
} else if (arg === "--smol" && i + 1 < args.length) {
|
||||
result.smol = args[++i];
|
||||
} else if (arg === "--slow" && i + 1 < args.length) {
|
||||
result.slow = args[++i];
|
||||
} else if (arg === "--plan" && i + 1 < args.length) {
|
||||
result.plan = args[++i];
|
||||
} else if (arg === "--api-key" && i + 1 < args.length) {
|
||||
result.apiKey = args[++i];
|
||||
} else if (arg === "--system-prompt" && i + 1 < args.length) {
|
||||
result.systemPrompt = args[++i];
|
||||
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
|
||||
result.appendSystemPrompt = args[++i];
|
||||
} else if (arg === "--provider-session-id" && i + 1 < args.length) {
|
||||
result.providerSessionId = args[++i];
|
||||
} else if (arg === "--no-session") {
|
||||
result.noSession = true;
|
||||
} else if (arg === "--session-dir" && i + 1 < args.length) {
|
||||
result.sessionDir = args[++i];
|
||||
} else if (arg === "--models" && i + 1 < args.length) {
|
||||
result.models = args[++i].split(",").map(s => s.trim());
|
||||
} else if (arg === "--no-tools") {
|
||||
result.noTools = true;
|
||||
} else if (arg === "--no-lsp") {
|
||||
result.noLsp = true;
|
||||
} else if (arg === "--no-pty") {
|
||||
result.noPty = true;
|
||||
} else if (arg === "--tools" && i + 1 < args.length) {
|
||||
const toolNames = args[++i]
|
||||
.split(",")
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const validTools: string[] = [];
|
||||
for (const name of toolNames) {
|
||||
if (name in BUILTIN_TOOLS) {
|
||||
validTools.push(name);
|
||||
} else {
|
||||
logger.warn("Unknown tool passed to --tools", {
|
||||
tool: name,
|
||||
validTools: Object.keys(BUILTIN_TOOLS),
|
||||
});
|
||||
}
|
||||
}
|
||||
result.tools = validTools;
|
||||
} else if (arg === "--thinking" && i + 1 < args.length) {
|
||||
const rawThinking = args[++i];
|
||||
const thinking = parseEffort(rawThinking);
|
||||
if (thinking !== undefined) {
|
||||
result.thinking = thinking;
|
||||
} else {
|
||||
logger.warn("Invalid thinking level passed to --thinking", {
|
||||
level: rawThinking,
|
||||
validThinkingLevels: THINKING_EFFORTS,
|
||||
});
|
||||
}
|
||||
} else if (arg === "--hide-thinking") {
|
||||
result.hideThinking = true;
|
||||
} else if (arg === "--print" || arg === "-p") {
|
||||
result.print = true;
|
||||
} else if (arg === "--export" && i + 1 < args.length) {
|
||||
result.export = args[++i];
|
||||
} else if (arg === "--hook" && i + 1 < args.length) {
|
||||
result.hooks = result.hooks ?? [];
|
||||
result.hooks.push(args[++i]);
|
||||
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {
|
||||
result.extensions = result.extensions ?? [];
|
||||
result.extensions.push(args[++i]);
|
||||
} else if (arg === "--plugin-dir" && i + 1 < args.length) {
|
||||
result.pluginDirs = result.pluginDirs ?? [];
|
||||
result.pluginDirs.push(args[++i]);
|
||||
} else if (arg === "--no-extensions") {
|
||||
result.noExtensions = true;
|
||||
} else if (arg === "--no-skills") {
|
||||
@@ -236,19 +206,6 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
result.noTitle = true;
|
||||
} else if (arg === "--auto-approve" || arg === "--yolo") {
|
||||
result.autoApprove = true;
|
||||
} else if (arg === "--approval-mode" && i + 1 < args.length) {
|
||||
const mode = args[++i];
|
||||
if (mode === "always-ask" || mode === "write" || mode === "yolo") {
|
||||
result.approvalMode = mode;
|
||||
} else {
|
||||
logger.warn("Invalid value passed to --approval-mode", {
|
||||
value: mode,
|
||||
validValues: ["always-ask", "write", "yolo"],
|
||||
});
|
||||
}
|
||||
} else if (arg === "--skills" && i + 1 < args.length) {
|
||||
// Comma-separated glob patterns for skill filtering
|
||||
result.skills = args[++i].split(",").map(s => s.trim());
|
||||
} else if (arg.startsWith("@")) {
|
||||
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
||||
} else if (!arg.startsWith("-") || arg === "-") {
|
||||
@@ -344,13 +301,14 @@ export function getExtraHelpText(): string {
|
||||
ANTHROPIC_SEARCH_BASE_URL - Anthropic web search base URL (override; pairs with ANTHROPIC_SEARCH_API_KEY)
|
||||
|
||||
${chalk.dim("# Configuration")}
|
||||
OMP_PROFILE - Named profile for isolated agent state (same as --profile)
|
||||
Use \`omp --profile <name> --alias <command>\` to create a shell shortcut for a profile
|
||||
PI_CODING_AGENT_DIR - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)
|
||||
PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths)
|
||||
PI_SMOL_MODEL - Override smol/fast model (see --smol)
|
||||
PI_SLOW_MODEL - Override slow/reasoning model (see --slow)
|
||||
PI_PLAN_MODEL - Override planning model (see --plan)
|
||||
PI_NO_PTY - Disable PTY-based interactive bash execution
|
||||
|
||||
For complete environment variable reference, see:
|
||||
${chalk.dim("docs/environment-variables.md")}
|
||||
${chalk.bold("Available Tools (default-enabled unless noted):")}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* knob and is keyed by flag name so it stays stable as flags are added.
|
||||
*/
|
||||
import type { ArgDescriptor, CliConfig, CommandCtor, FlagDescriptor } from "@oh-my-pi/pi-utils/cli";
|
||||
import { BUILTIN_TOOLS } from "../tools";
|
||||
import { BUILTIN_TOOL_NAMES } from "../tools/builtin-names";
|
||||
|
||||
export type Shell = "bash" | "zsh" | "fish";
|
||||
|
||||
@@ -77,7 +77,7 @@ function flagValue(name: string, desc: FlagDescriptor): ValueSource {
|
||||
if (MODEL_FLAGS[name]) return { kind: "models", multiple: false };
|
||||
if (name === "models") return { kind: "models", multiple: true };
|
||||
if (SESSION_FLAGS[name]) return { kind: "sessions" };
|
||||
if (name === "tools") return { kind: "list", values: Object.keys(BUILTIN_TOOLS) };
|
||||
if (name === "tools") return { kind: "list", values: BUILTIN_TOOL_NAMES };
|
||||
if (DIR_FLAGS[name]) return { kind: "dir" };
|
||||
if (desc.kind === "integer") return { kind: "value" };
|
||||
return { kind: "file" };
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Single source of truth for argv flag classification, shared by:
|
||||
* - `parseArgs` in `./args.ts` (the launch-time CLI parser)
|
||||
* - `extractProfileFlags` in `./profile-bootstrap.ts` (the early
|
||||
* `--profile` / `--alias` pre-parser)
|
||||
*
|
||||
* `parseArgs` dispatches string-valued flags by looking up their setter in
|
||||
* {@link STRING_SETTERS}. Optional-value flags use {@link OPTIONAL_FLAGS} so
|
||||
* per-flag quirks (currently empty-string rejection for `--resume`) live here
|
||||
* instead of being hard-coded in the dispatch loop.
|
||||
*
|
||||
* The bootstrap doesn't dispatch — it only needs to know which flags consume
|
||||
* a value — so it consults {@link STRING_VALUE_FLAGS} and
|
||||
* {@link OPTIONAL_VALUE_FLAGS}, both derived from `Object.keys(...)` on the
|
||||
* setter/config records below.
|
||||
*
|
||||
* The deliberate consequence: a string-valued flag exists in this CLI surface
|
||||
* iff it has an entry here. Adding a new string-valued flag means adding a
|
||||
* setter/config entry in this file; both `args.ts` and the bootstrap pick it
|
||||
* up automatically, so the two cannot drift out of sync.
|
||||
*
|
||||
* IMPORT RULE: this module MUST NOT import any runtime value from
|
||||
* `@oh-my-pi/pi-utils` (or anything that transitively does). That package's
|
||||
* `env.ts` eagerly loads `.env` files from `getAgentDir()` during module
|
||||
* initialization, which would race the profile bootstrap. Type-only imports
|
||||
* are erased at runtime and are therefore safe.
|
||||
*
|
||||
* If a setter needs runtime dependencies (logging, validators, lookup
|
||||
* tables), they're passed in through {@link ParseDeps} and `args.ts` wires the
|
||||
* real implementations at the dispatch site.
|
||||
*/
|
||||
|
||||
import type { Effort } from "@oh-my-pi/pi-ai";
|
||||
import type { Args } from "./args";
|
||||
|
||||
/**
|
||||
* Runtime dependencies injected into setters that need to validate input or
|
||||
* warn about bad values. `args.ts` constructs one object at module load and
|
||||
* passes it to each {@link STRING_SETTERS} call.
|
||||
*
|
||||
* Keeping these out of the setter closures means this module stays free of
|
||||
* runtime imports from `@oh-my-pi/pi-utils`, which is the whole reason it can
|
||||
* be safely imported by `profile-bootstrap.ts` before `setProfile` runs.
|
||||
*/
|
||||
export interface ParseDeps {
|
||||
logger: { warn: (message: string, meta?: Record<string, unknown>) => void };
|
||||
parseEffort: (value: string | null | undefined) => Effort | undefined;
|
||||
builtinToolNames: readonly string[];
|
||||
thinkingEfforts: readonly string[];
|
||||
}
|
||||
|
||||
export type StringSetter = (result: Args, value: string, deps: ParseDeps) => void;
|
||||
|
||||
/**
|
||||
* Setter for a flag that may or may not consume the next argv token.
|
||||
* Receives `undefined` for the bare form (`--resume` with no value, etc.).
|
||||
*/
|
||||
export type OptionalSetter = (result: Args, value: string | undefined) => void;
|
||||
|
||||
/**
|
||||
* Per-flag optional-value consumption policy.
|
||||
*
|
||||
* Every optional flag always rejects tokens that start with `-` — that shared
|
||||
* rule lives in the dispatch site. These booleans capture the *additional*
|
||||
* per-flag quirks:
|
||||
*
|
||||
* - `rejectEmpty`: treat `""` like “no value provided”. Needed for
|
||||
* `--resume` / `-r` / `--session`. Without it, an empty string
|
||||
* gets consumed as the session prefix and downstream resolution can match
|
||||
* every session.
|
||||
*/
|
||||
export interface OptionalFlagConfig {
|
||||
set: OptionalSetter;
|
||||
rejectEmpty?: boolean;
|
||||
}
|
||||
|
||||
// Shared setters for flags that alias the same field.
|
||||
const setExtension: StringSetter = (result, value) => {
|
||||
result.extensions = result.extensions ?? [];
|
||||
result.extensions.push(value);
|
||||
};
|
||||
|
||||
const setResume: OptionalSetter = (result, value) => {
|
||||
result.resume = value !== undefined ? value : true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Setters for flags with string values. Most built-ins consume the next argv
|
||||
* token even when it starts with `-`; flags listed in
|
||||
* {@link EXTENSION_SHADOWABLE_STRING_FLAGS} use extension-style consumption so
|
||||
* a registered boolean extension can shadow them before profile bootstrap.
|
||||
*/
|
||||
export const STRING_SETTERS: Record<string, StringSetter> = {
|
||||
"--cwd": (result, value) => {
|
||||
result.cwd = value;
|
||||
},
|
||||
"--config": (result, value) => {
|
||||
result.config = [...(result.config ?? []), value];
|
||||
},
|
||||
"--mode": (result, value) => {
|
||||
if (value === "text" || value === "json" || value === "rpc" || value === "acp" || value === "rpc-ui") {
|
||||
result.mode = value;
|
||||
}
|
||||
},
|
||||
"--fork": (result, value) => {
|
||||
result.fork = value;
|
||||
},
|
||||
"--provider": (result, value) => {
|
||||
result.provider = value;
|
||||
},
|
||||
"--model": (result, value) => {
|
||||
result.model = value;
|
||||
},
|
||||
"--smol": (result, value) => {
|
||||
result.smol = value;
|
||||
},
|
||||
"--slow": (result, value) => {
|
||||
result.slow = value;
|
||||
},
|
||||
"--plan": (result, value) => {
|
||||
result.plan = value;
|
||||
},
|
||||
"--api-key": (result, value) => {
|
||||
result.apiKey = value;
|
||||
},
|
||||
"--system-prompt": (result, value) => {
|
||||
result.systemPrompt = value;
|
||||
},
|
||||
"--append-system-prompt": (result, value) => {
|
||||
result.appendSystemPrompt = value;
|
||||
},
|
||||
"--provider-session-id": (result, value) => {
|
||||
result.providerSessionId = value;
|
||||
},
|
||||
"--session-dir": (result, value) => {
|
||||
result.sessionDir = value;
|
||||
},
|
||||
"--models": (result, value) => {
|
||||
result.models = value.split(",").map(s => s.trim());
|
||||
},
|
||||
"--tools": (result, value, deps) => {
|
||||
const names = value
|
||||
.split(",")
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const valid: string[] = [];
|
||||
for (const name of names) {
|
||||
if (deps.builtinToolNames.includes(name)) {
|
||||
valid.push(name);
|
||||
} else {
|
||||
deps.logger.warn("Unknown tool passed to --tools", {
|
||||
tool: name,
|
||||
validTools: deps.builtinToolNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
result.tools = valid;
|
||||
},
|
||||
"--thinking": (result, value, deps) => {
|
||||
const thinking = deps.parseEffort(value);
|
||||
if (thinking !== undefined) {
|
||||
result.thinking = thinking;
|
||||
} else {
|
||||
deps.logger.warn("Invalid thinking level passed to --thinking", {
|
||||
level: value,
|
||||
validThinkingLevels: deps.thinkingEfforts,
|
||||
});
|
||||
}
|
||||
},
|
||||
"--export": (result, value) => {
|
||||
result.export = value;
|
||||
},
|
||||
"--hook": (result, value) => {
|
||||
result.hooks = result.hooks ?? [];
|
||||
result.hooks.push(value);
|
||||
},
|
||||
"--extension": setExtension,
|
||||
"-e": setExtension,
|
||||
"--plugin-dir": (result, value) => {
|
||||
result.pluginDirs = result.pluginDirs ?? [];
|
||||
result.pluginDirs.push(value);
|
||||
},
|
||||
"--skills": (result, value) => {
|
||||
result.skills = value.split(",").map(s => s.trim());
|
||||
},
|
||||
"--approval-mode": (result, value, deps) => {
|
||||
if (value === "always-ask" || value === "write" || value === "yolo") {
|
||||
result.approvalMode = value;
|
||||
} else {
|
||||
deps.logger.warn("Invalid value passed to --approval-mode", {
|
||||
value,
|
||||
validValues: ["always-ask", "write", "yolo"],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional-value flags. Setters receive `undefined` for the bare form.
|
||||
*
|
||||
* The dispatch in `args.ts` applies the shared "doesn't start with `-`"
|
||||
* check for every flag, then consults the per-flag booleans below for the
|
||||
* remaining quirks.
|
||||
*/
|
||||
export const OPTIONAL_FLAGS: Record<string, OptionalFlagConfig> = {
|
||||
"--resume": { set: setResume, rejectEmpty: true },
|
||||
"-r": { set: setResume, rejectEmpty: true },
|
||||
"--session": { set: setResume, rejectEmpty: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Derived from {@link STRING_SETTERS}. A flag is in this set if and only if
|
||||
* it has a setter — by construction, drift between "the bootstrap thinks
|
||||
* this flag accepts a value" and "the launch parser can set one" is
|
||||
* structurally impossible.
|
||||
*/
|
||||
export const STRING_VALUE_FLAGS: ReadonlySet<string> = new Set(Object.keys(STRING_SETTERS));
|
||||
|
||||
/**
|
||||
* Built-in string flags known to be shadowed by bundled/common boolean
|
||||
* extensions before extension metadata is available. They still accept a
|
||||
* value-like successor for the built-in form (`--plan opus`), but a
|
||||
* flag-looking successor remains a fresh flag (`--plan --profile work`).
|
||||
*/
|
||||
export const EXTENSION_SHADOWABLE_STRING_FLAGS: ReadonlySet<string> = new Set(["--plan"]);
|
||||
|
||||
/**
|
||||
* Derived from {@link OPTIONAL_FLAGS}. Same single-source contract as
|
||||
* {@link STRING_VALUE_FLAGS}.
|
||||
*/
|
||||
export const OPTIONAL_VALUE_FLAGS: ReadonlySet<string> = new Set(Object.keys(OPTIONAL_FLAGS));
|
||||
|
||||
/**
|
||||
* Internal marker inserted by the profile bootstrap when removing `--profile`
|
||||
* or `--alias` would otherwise make the following value-like token become the
|
||||
* value of a preceding optional/extension flag. `parseArgs` ignores it, but its
|
||||
* flag-looking shape preserves argv boundaries during the second parse.
|
||||
*/
|
||||
export const PROFILE_BOOTSTRAP_BOUNDARY_ARG = "--omp-profile-boundary";
|
||||
|
||||
/**
|
||||
* Long-form launch flags that take NO value (booleans). The bootstrap pre-parser
|
||||
* needs this to tell a known value-less flag (whose successor is a fresh
|
||||
* argument — `omp --print --profile work` still selects a profile) apart from an
|
||||
* UNKNOWN long option that might be an extension string flag consuming the next
|
||||
* token as its value (so the bootstrap must not steal that token as a global
|
||||
* `--profile`/`--alias`). MUST mirror the value-less flag arms of `parseArgs`
|
||||
* in `./args.ts`: adding a new boolean launch flag there means adding it here,
|
||||
* or `--<newflag> --profile X` stops selecting a profile. Short aliases
|
||||
* (`-h`/`-v`/`-c`/`-p`) are intentionally omitted — the protection rule only
|
||||
* fires for `--`-prefixed tokens.
|
||||
*/
|
||||
export const VALUELESS_FLAGS: ReadonlySet<string> = new Set([
|
||||
"--help",
|
||||
"--version",
|
||||
"--allow-home",
|
||||
"--continue",
|
||||
"--no-session",
|
||||
"--no-tools",
|
||||
"--no-lsp",
|
||||
"--no-pty",
|
||||
"--hide-thinking",
|
||||
"--print",
|
||||
"--no-extensions",
|
||||
"--no-skills",
|
||||
"--no-rules",
|
||||
"--no-title",
|
||||
"--auto-approve",
|
||||
"--yolo",
|
||||
]);
|
||||
@@ -0,0 +1,338 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { normalizeProfileName } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
export type ProfileAliasShell = "bash" | "zsh" | "fish" | "powershell" | "pwsh";
|
||||
|
||||
function quoteForShell(pathValue: string): string {
|
||||
return `'${pathValue.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quoteForPowerShell(pathValue: string): string {
|
||||
return `'${pathValue.replace(/'/g, `''`)}'`;
|
||||
}
|
||||
|
||||
export interface ProfileAliasCommand {
|
||||
display: string;
|
||||
posix: string;
|
||||
fish: string;
|
||||
powerShell: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ALIAS_COMMAND: ProfileAliasCommand = {
|
||||
display: "omp",
|
||||
posix: "omp",
|
||||
fish: "omp",
|
||||
powerShell: "omp",
|
||||
};
|
||||
|
||||
export interface ProfileAliasInstallOptions {
|
||||
profile: string;
|
||||
aliasName: string;
|
||||
shellPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
readFile?: (filePath: string) => Promise<string>;
|
||||
command?: ProfileAliasCommand;
|
||||
writeFile?: (filePath: string, content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProfileAliasInstallResult {
|
||||
shell: ProfileAliasShell;
|
||||
configPath: string;
|
||||
aliasName: string;
|
||||
profile: string;
|
||||
command: string;
|
||||
reloadedWith: string;
|
||||
}
|
||||
|
||||
const ALIAS_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
||||
const POSIX_RESERVED_ALIAS_NAMES: ReadonlySet<string> = new Set([
|
||||
"case",
|
||||
"coproc",
|
||||
"do",
|
||||
"done",
|
||||
"elif",
|
||||
"else",
|
||||
"esac",
|
||||
"fi",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"in",
|
||||
"select",
|
||||
"then",
|
||||
"time",
|
||||
"until",
|
||||
"while",
|
||||
]);
|
||||
const FISH_RESERVED_ALIAS_NAMES: ReadonlySet<string> = new Set([
|
||||
"and",
|
||||
"begin",
|
||||
"break",
|
||||
"builtin",
|
||||
"case",
|
||||
"command",
|
||||
"continue",
|
||||
"else",
|
||||
"end",
|
||||
"exec",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"not",
|
||||
"or",
|
||||
"return",
|
||||
"switch",
|
||||
"while",
|
||||
]);
|
||||
const POWERSHELL_RESERVED_ALIAS_NAMES: ReadonlySet<string> = new Set([
|
||||
"begin",
|
||||
"break",
|
||||
"catch",
|
||||
"class",
|
||||
"continue",
|
||||
"data",
|
||||
"do",
|
||||
"dynamicparam",
|
||||
"else",
|
||||
"elseif",
|
||||
"end",
|
||||
"enum",
|
||||
"exit",
|
||||
"filter",
|
||||
"finally",
|
||||
"for",
|
||||
"foreach",
|
||||
"from",
|
||||
"function",
|
||||
"if",
|
||||
"in",
|
||||
"param",
|
||||
"process",
|
||||
"return",
|
||||
"switch",
|
||||
"throw",
|
||||
"trap",
|
||||
"try",
|
||||
"until",
|
||||
"using",
|
||||
"var",
|
||||
"while",
|
||||
"workflow",
|
||||
]);
|
||||
|
||||
// Keep local: importing the pi-utils root here would eagerly load env before
|
||||
// cli.ts has applied --profile, regressing profile-specific .env loading.
|
||||
function isEnoentError(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "ENOENT";
|
||||
}
|
||||
|
||||
function getReservedAliasNames(shell: ProfileAliasShell): ReadonlySet<string> {
|
||||
switch (shell) {
|
||||
case "bash":
|
||||
case "zsh":
|
||||
return POSIX_RESERVED_ALIAS_NAMES;
|
||||
case "fish":
|
||||
return FISH_RESERVED_ALIAS_NAMES;
|
||||
case "powershell":
|
||||
case "pwsh":
|
||||
return POWERSHELL_RESERVED_ALIAS_NAMES;
|
||||
}
|
||||
}
|
||||
|
||||
function validateAliasName(aliasName: string, shell: ProfileAliasShell): string {
|
||||
const normalized = aliasName.trim();
|
||||
if (!ALIAS_NAME_RE.test(normalized)) {
|
||||
throw new Error(`Invalid alias "${aliasName}". Alias names must match ${ALIAS_NAME_RE.source}.`);
|
||||
}
|
||||
if (normalized.toLowerCase() === "omp") {
|
||||
throw new Error('Invalid alias "omp". Refusing to shadow the base omp command.');
|
||||
}
|
||||
if (getReservedAliasNames(shell).has(normalized.toLowerCase())) {
|
||||
throw new Error(`Invalid alias "${aliasName}". Refusing to create a ${shell} reserved word.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// On Windows the launching shell is rarely exported through $SHELL, so when it
|
||||
// is missing we infer the PowerShell edition from the inherited environment.
|
||||
// PowerShell 7 (pwsh) always seeds PSModulePath with separator-delimited
|
||||
// ".../PowerShell/..." module directories (plus the Windows PowerShell ones for
|
||||
// back-compat), whereas Windows PowerShell 5.1 only ever lists
|
||||
// ".../WindowsPowerShell/...". The separator anchors keep "WindowsPowerShell"
|
||||
// from matching. POWERSHELL_DISTRIBUTION_CHANNEL is set only by some pwsh
|
||||
// distributions, so it stays a secondary hint rather than the primary signal.
|
||||
function detectWindowsPowerShell(env: NodeJS.ProcessEnv): ProfileAliasShell {
|
||||
const modulePath = env.PSModulePath ?? env.PSMODULEPATH ?? env.psmodulepath ?? "";
|
||||
if (/[\\/]PowerShell[\\/]/i.test(modulePath)) return "pwsh";
|
||||
if (env.POWERSHELL_DISTRIBUTION_CHANNEL) return "pwsh";
|
||||
return "powershell";
|
||||
}
|
||||
|
||||
function normalizeShellName(
|
||||
shellPath: string | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): ProfileAliasShell {
|
||||
const shell = path
|
||||
.basename(shellPath ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\.exe$/, "");
|
||||
if (shell === "zsh") return "zsh";
|
||||
if (shell === "bash") return "bash";
|
||||
if (shell === "fish") return "fish";
|
||||
if (shell === "pwsh") return "pwsh";
|
||||
if (shell === "powershell") return "powershell";
|
||||
if (platform === "win32") return detectWindowsPowerShell(env);
|
||||
throw new Error(`Unsupported shell${shell ? ` "${shell}"` : ""}. Supported shells: bash, zsh, fish, PowerShell.`);
|
||||
}
|
||||
|
||||
export function resolveProfileAliasCommandFromProcess(
|
||||
argv: readonly string[] = process.argv,
|
||||
cwd: string = process.cwd(),
|
||||
): ProfileAliasCommand {
|
||||
const runtime = argv[0];
|
||||
const script = argv[1];
|
||||
if (!runtime || !script || !/\.[cm]?[jt]s$/.test(script)) return DEFAULT_ALIAS_COMMAND;
|
||||
|
||||
const scriptPath = path.resolve(cwd, script);
|
||||
const posix = `${quoteForShell(runtime)} ${quoteForShell(scriptPath)}`;
|
||||
return {
|
||||
display: `${runtime} ${scriptPath}`,
|
||||
posix,
|
||||
fish: posix,
|
||||
powerShell: `${quoteForPowerShell(runtime)} ${quoteForPowerShell(scriptPath)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveShellConfigPath(
|
||||
shell: ProfileAliasShell,
|
||||
homeDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
switch (shell) {
|
||||
case "zsh":
|
||||
return path.join(env.ZDOTDIR || homeDir, ".zshrc");
|
||||
case "bash":
|
||||
return platform === "darwin" ? path.join(homeDir, ".bash_profile") : path.join(homeDir, ".bashrc");
|
||||
case "fish": {
|
||||
// fish sources conf.d from $XDG_CONFIG_HOME/fish (default ~/.config/fish);
|
||||
// a hard-coded ~/.config would be silently ignored when the user relocates
|
||||
// their XDG config root, leaving the alias unsourced after a restart.
|
||||
const configHome = env.XDG_CONFIG_HOME || path.join(homeDir, ".config");
|
||||
return path.join(configHome, "fish", "conf.d", "omp-profiles.fish");
|
||||
}
|
||||
case "pwsh":
|
||||
return platform === "win32"
|
||||
? path.join(homeDir, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1")
|
||||
: path.join(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1");
|
||||
case "powershell":
|
||||
return path.join(homeDir, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1");
|
||||
}
|
||||
}
|
||||
|
||||
function renderAliasBlock(
|
||||
shell: ProfileAliasShell,
|
||||
aliasName: string,
|
||||
profile: string,
|
||||
command: ProfileAliasCommand,
|
||||
): { block: string; command: string } {
|
||||
const profiledCommand = `${command.display} --profile=${profile}`;
|
||||
const start = `# >>> omp profile alias: ${aliasName} >>>`;
|
||||
const end = `# <<< omp profile alias: ${aliasName} <<<`;
|
||||
let body: string;
|
||||
switch (shell) {
|
||||
case "fish":
|
||||
body = [
|
||||
`function ${aliasName} --wraps omp --description 'OMP profile ${profile}'`,
|
||||
` command ${command.fish} --profile=${profile} $argv`,
|
||||
"end",
|
||||
].join("\n");
|
||||
break;
|
||||
case "powershell":
|
||||
case "pwsh":
|
||||
body = [`function ${aliasName} {`, ` & ${command.powerShell} --profile=${profile} @args`, "}"].join("\n");
|
||||
break;
|
||||
default:
|
||||
body = [`${aliasName}() {`, ` command ${command.posix} --profile=${profile} "$@"`, "}"].join("\n");
|
||||
break;
|
||||
}
|
||||
return { block: `${start}\n${body}\n${end}`, command: profiledCommand };
|
||||
}
|
||||
|
||||
function upsertBlock(content: string, aliasName: string, block: string): string {
|
||||
const start = `# >>> omp profile alias: ${aliasName} >>>`;
|
||||
const end = `# <<< omp profile alias: ${aliasName} <<<`;
|
||||
const startIndex = content.indexOf(start);
|
||||
if (startIndex !== -1) {
|
||||
const endIndex = content.indexOf(end, startIndex + start.length);
|
||||
if (endIndex === -1) {
|
||||
throw new Error(
|
||||
`Found "${start}" without a matching "${end}" in the shell config. ` +
|
||||
`The managed alias block is malformed; remove the stale marker line and rerun --alias.`,
|
||||
);
|
||||
}
|
||||
const afterEnd = endIndex + end.length;
|
||||
const prefix = content.slice(0, startIndex).replace(/[\t ]*\n?$/, "");
|
||||
const suffix = content.slice(afterEnd).replace(/^\n?/, "");
|
||||
return [prefix, block, suffix].filter(Boolean).join("\n\n").replace(/\n*$/, "\n");
|
||||
}
|
||||
const trimmed = content.replace(/\s*$/, "");
|
||||
return `${trimmed}${trimmed ? "\n\n" : ""}${block}\n`;
|
||||
}
|
||||
|
||||
function readAliasConfigText(filePath: string): Promise<string> {
|
||||
return Bun.file(filePath).text();
|
||||
}
|
||||
|
||||
export async function readProfileAliasConfigFile(
|
||||
filePath: string,
|
||||
readText: (filePath: string) => Promise<string> = readAliasConfigText,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await readText(filePath);
|
||||
} catch (error) {
|
||||
if (isEnoentError(error)) return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installProfileAlias(options: ProfileAliasInstallOptions): Promise<ProfileAliasInstallResult> {
|
||||
const profile = normalizeProfileName(options.profile);
|
||||
if (!profile) {
|
||||
throw new Error("--alias requires a named --profile value.");
|
||||
}
|
||||
const platform = options.platform ?? process.platform;
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const env = options.env ?? process.env;
|
||||
const shell = normalizeShellName(options.shellPath ?? env.SHELL, platform, env);
|
||||
const aliasName = validateAliasName(options.aliasName, shell);
|
||||
const configPath = resolveShellConfigPath(shell, homeDir, platform, env);
|
||||
const { block, command } = renderAliasBlock(shell, aliasName, profile, options.command ?? DEFAULT_ALIAS_COMMAND);
|
||||
const readFile = options.readFile ?? readProfileAliasConfigFile;
|
||||
const writeFile =
|
||||
options.writeFile ??
|
||||
(async (filePath, content) => {
|
||||
await Bun.write(filePath, content);
|
||||
});
|
||||
|
||||
const current = await readFile(configPath);
|
||||
await writeFile(configPath, upsertBlock(current, aliasName, block));
|
||||
|
||||
return {
|
||||
shell,
|
||||
configPath,
|
||||
aliasName,
|
||||
profile,
|
||||
command,
|
||||
reloadedWith:
|
||||
shell === "fish"
|
||||
? `source ${quoteForShell(configPath)}`
|
||||
: shell === "powershell" || shell === "pwsh"
|
||||
? `. ${quoteForPowerShell(configPath)}`
|
||||
: `. ${quoteForShell(configPath)}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Bootstrap-time argv preparser for the global `--profile` / `--alias` flags.
|
||||
*
|
||||
* Profile selection MUST happen before any module reads `getAgentDir()` (notably
|
||||
* `@oh-my-pi/pi-utils/env`, which eagerly loads `.env` from the agent directory
|
||||
* during its own import). The full `parseArgs` from `./args.ts` lives downstream
|
||||
* of those imports, so we can't rely on it for profile bootstrap — we have to
|
||||
* crack open argv before the lazy command modules load.
|
||||
*
|
||||
* Because of that, this preparser must respect the same value-consumption
|
||||
* contract as `args.ts`: known string-valued flags usually consume the next
|
||||
* token even when it starts with `-`, except for string flags that can be
|
||||
* shadowed by preloaded boolean extensions (currently `--plan`). Optional-value
|
||||
* flags (`--resume`, `--session`, `-r`) consume the next token only when it
|
||||
* doesn't look like another flag. Without this, `omp --system-prompt --profile
|
||||
* foo` silently activates profile `foo`
|
||||
* instead of passing the literal `--profile` to the system prompt and `foo`
|
||||
* as a positional message.
|
||||
*
|
||||
* The shared classification lives in {@link ./flag-tables}, imported below,
|
||||
* so the bootstrap and `args.ts` reference one source of truth instead of
|
||||
* maintaining parallel constants.
|
||||
*
|
||||
* An unclassified bare long option (one not in any flag table) is treated as a
|
||||
* possible extension string flag, but the bootstrap mirrors `parseArgs`'
|
||||
* extension-flag rules ({@link ./args}): a string extension flag consumes its
|
||||
* successor ONLY when that successor is value-like (does not start with `-`), and
|
||||
* a boolean extension flag consumes nothing. So the successor is forwarded
|
||||
* untouched (and never read as a global `--profile`/`--alias`) only when it is
|
||||
* value-like; a flag-looking successor is left for normal processing, so
|
||||
* `omp --some-ext-flag --profile work` still selects a profile. Known value-less
|
||||
* launch flags ({@link VALUELESS_FLAGS}) are exempt so a trailing profile after
|
||||
* them also activates (`omp --print --profile work`).
|
||||
*/
|
||||
|
||||
import { isSubcommand } from "../cli-commands";
|
||||
import {
|
||||
EXTENSION_SHADOWABLE_STRING_FLAGS,
|
||||
OPTIONAL_FLAGS,
|
||||
OPTIONAL_VALUE_FLAGS,
|
||||
PROFILE_BOOTSTRAP_BOUNDARY_ARG,
|
||||
STRING_VALUE_FLAGS,
|
||||
VALUELESS_FLAGS,
|
||||
} from "./flag-tables";
|
||||
|
||||
function isProfileBootstrapSubcommand(arg: string): boolean {
|
||||
return arg === "launch" || arg === "acp";
|
||||
}
|
||||
|
||||
function isUnknownLongValueCandidate(arg: string): boolean {
|
||||
return (
|
||||
arg.startsWith("--") &&
|
||||
!arg.includes("=") &&
|
||||
!STRING_VALUE_FLAGS.has(arg) &&
|
||||
!OPTIONAL_VALUE_FLAGS.has(arg) &&
|
||||
!VALUELESS_FLAGS.has(arg)
|
||||
);
|
||||
}
|
||||
|
||||
function needsBoundaryAfterGlobalStrip(stripped: readonly string[]): boolean {
|
||||
const previous = stripped[stripped.length - 1];
|
||||
return (
|
||||
previous !== undefined &&
|
||||
(OPTIONAL_VALUE_FLAGS.has(previous) ||
|
||||
EXTENSION_SHADOWABLE_STRING_FLAGS.has(previous) ||
|
||||
isUnknownLongValueCandidate(previous))
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProfileBootstrapResult {
|
||||
argv: string[];
|
||||
profile?: string;
|
||||
aliasName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `--profile` / `--alias` from argv while preserving the surrounding
|
||||
* argument structure, returning the residual argv to hand to the launch parser
|
||||
* and the captured flag values.
|
||||
*
|
||||
* Global flag extraction stops only when the first residual argv token names a
|
||||
* registered command that owns its own flags (e.g. `grep`): everything from
|
||||
* that token onward is forwarded verbatim so a subcommand's own flags and
|
||||
* positionals are never stolen (`omp grep --profile <path>` greps for
|
||||
* `--profile`; it does not select a profile). `launch` and `acp` are explicit
|
||||
* spellings of launch-shaped commands, so `omp launch --profile work` and
|
||||
* `omp acp --profile work` still select profile `work`.
|
||||
*
|
||||
* Throws when either flag is supplied without a value.
|
||||
*/
|
||||
export function extractProfileFlags(argv: readonly string[]): ProfileBootstrapResult {
|
||||
const stripped: string[] = [];
|
||||
let profile: string | undefined;
|
||||
let aliasName: string | undefined;
|
||||
let passThrough = false;
|
||||
let sawSubcommand = false;
|
||||
let canDispatchSubcommand = true;
|
||||
let insertBoundaryBeforeNextValue = false;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (passThrough || sawSubcommand) {
|
||||
stripped.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (insertBoundaryBeforeNextValue) {
|
||||
if (!arg.startsWith("-")) {
|
||||
stripped.push(PROFILE_BOOTSTRAP_BOUNDARY_ARG);
|
||||
}
|
||||
insertBoundaryBeforeNextValue = false;
|
||||
}
|
||||
|
||||
// `--` ends option processing. Anything that follows is forwarded verbatim
|
||||
// so users can pass arbitrary tokens (including a literal `--profile`) to
|
||||
// downstream tools without the bootstrap stealing them.
|
||||
if (arg === "--") {
|
||||
passThrough = true;
|
||||
stripped.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--profile") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error("--profile requires a profile name");
|
||||
}
|
||||
profile = value;
|
||||
insertBoundaryBeforeNextValue = needsBoundaryAfterGlobalStrip(stripped);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--profile=")) {
|
||||
const value = arg.slice("--profile=".length);
|
||||
if (!value) {
|
||||
throw new Error("--profile requires a profile name");
|
||||
}
|
||||
profile = value;
|
||||
insertBoundaryBeforeNextValue = needsBoundaryAfterGlobalStrip(stripped);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--alias") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error("--alias requires a command name");
|
||||
}
|
||||
aliasName = value;
|
||||
insertBoundaryBeforeNextValue = needsBoundaryAfterGlobalStrip(stripped);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--alias=")) {
|
||||
const value = arg.slice("--alias=".length);
|
||||
if (!value) {
|
||||
throw new Error("--alias requires a command name");
|
||||
}
|
||||
aliasName = value;
|
||||
insertBoundaryBeforeNextValue = needsBoundaryAfterGlobalStrip(stripped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Known string flags normally consume flag-looking values (for example
|
||||
// `--system-prompt --profile foo` means the system prompt is literally
|
||||
// `--profile`). A small allow-list of built-ins can be shadowed by boolean
|
||||
// extensions before extension metadata is loaded; those mirror extension
|
||||
// consumption here so `--plan --profile work` still activates `work`.
|
||||
if (EXTENSION_SHADOWABLE_STRING_FLAGS.has(arg)) {
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
const next = argv[index + 1];
|
||||
if (next !== undefined && !next.startsWith("-")) {
|
||||
stripped.push(next);
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Forward both the flag and its value untouched so the downstream parser
|
||||
// gets exactly what the user typed. Critical for `--system-prompt
|
||||
// --profile foo`: the bootstrap must NOT interpret `--profile` here, it
|
||||
// belongs to `--system-prompt`.
|
||||
if (STRING_VALUE_FLAGS.has(arg)) {
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
if (index + 1 < argv.length) {
|
||||
stripped.push(argv[index + 1]);
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (OPTIONAL_VALUE_FLAGS.has(arg)) {
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
const config = OPTIONAL_FLAGS[arg];
|
||||
const next = argv[index + 1];
|
||||
if (next !== undefined && !next.startsWith("-") && !(config.rejectEmpty === true && next.length === 0)) {
|
||||
stripped.push(next);
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// An unclassified bare long option (`--xxx` with no `=`) may be an extension
|
||||
// string flag that consumes the next token as its value. The bootstrap runs
|
||||
// before extensions load, so it cannot consult the extension flag table; it
|
||||
// therefore mirrors the value-consumption rule `parseArgs` applies to
|
||||
// extension flags (./args.ts): a string extension flag consumes its successor
|
||||
// ONLY when that successor is value-like (does not start with `-`), and a
|
||||
// boolean extension flag consumes nothing. So protect (forward + skip) the
|
||||
// successor only when it is value-like — `omp --bar val --profile work` keeps
|
||||
// `val` with `--bar` and still extracts the trailing profile — and otherwise
|
||||
// forward just the flag, letting the loop process a flag-looking successor so
|
||||
// a trailing global flag still applies (`omp --some-ext-bool --profile work`
|
||||
// selects profile `work`). A `--` successor is deliberately NOT protected
|
||||
// here: it falls through to the end-of-options arm above, keeping `--` a
|
||||
// single, consistent meaning instead of being swallowed as a flag value.
|
||||
// Known value-less launch flags are exempt so a trailing profile still
|
||||
// activates (`omp --print --profile work`).
|
||||
if (isUnknownLongValueCandidate(arg)) {
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
const next = argv[index + 1];
|
||||
if (next !== undefined && !next.startsWith("-")) {
|
||||
stripped.push(next);
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only the first residual argv token can be the dispatched subcommand. Once
|
||||
// any other token has been forwarded, later subcommand names are launch text.
|
||||
// `launch` and `acp` are explicit spellings of launch-shaped commands, so
|
||||
// global launch flags that follow them must still be extracted.
|
||||
if (canDispatchSubcommand && isSubcommand(arg) && !isProfileBootstrapSubcommand(arg)) {
|
||||
sawSubcommand = true;
|
||||
}
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
}
|
||||
|
||||
return { argv: stripped, profile, aliasName };
|
||||
}
|
||||
@@ -49,6 +49,12 @@ export default class Index extends Command {
|
||||
"allow-home": Flags.boolean({
|
||||
description: "Allow starting in ~ without auto-switching to a temp dir",
|
||||
}),
|
||||
profile: Flags.string({
|
||||
description: "Use an isolated profile for auth, sessions, settings, and caches",
|
||||
}),
|
||||
alias: Flags.string({
|
||||
description: "Create a shell shortcut for the selected profile and exit",
|
||||
}),
|
||||
cwd: Flags.string({
|
||||
description: "Directory to start in (overrides the launch cwd)",
|
||||
}),
|
||||
@@ -151,6 +157,7 @@ export default class Index extends Command {
|
||||
`# Include files in initial message\n ${APP_NAME} @prompt.md @image.png "What color is the sky?"`,
|
||||
`# Non-interactive mode (process and exit)\n ${APP_NAME} -p "List all .ts files in src/"`,
|
||||
`# Continue previous session\n ${APP_NAME} --continue "What did we discuss?"`,
|
||||
`# Create a shell shortcut for a work profile\n ${APP_NAME} --profile work --alias omp-work`,
|
||||
`# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
|
||||
`# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
|
||||
`# Export a session file to HTML\n ${APP_NAME} --export ~/.omp/agent/sessions/--path--/session.jsonl`,
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
},
|
||||
"callbackPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "OAuth `prompt` parameter sent during authorization (default: \"consent\" so the provider always shows its account/consent screen; set to \"\" to omit)."
|
||||
}
|
||||
},
|
||||
"description": "Explicit OAuth client settings for servers that need them during /mcp reauth or initial connect."
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task";
|
||||
export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "title" | "task";
|
||||
|
||||
export interface ModelRoleInfo {
|
||||
tag?: string;
|
||||
name: string;
|
||||
color?: ThemeColor;
|
||||
/** If true, the role is functional but not shown in the model selector UI. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
||||
@@ -21,12 +23,22 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
||||
plan: { tag: "PLAN", name: "Architect", color: "muted" },
|
||||
designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
|
||||
commit: { tag: "COMMIT", name: "Commit", color: "dim" },
|
||||
title: { tag: "TITLE", name: "Title", color: "dim", hidden: true },
|
||||
task: { tag: "TASK", name: "Subtask", color: "muted" },
|
||||
};
|
||||
|
||||
export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"];
|
||||
export const MODEL_ROLE_IDS: ModelRole[] = [
|
||||
"default",
|
||||
"smol",
|
||||
"slow",
|
||||
"vision",
|
||||
"plan",
|
||||
"designer",
|
||||
"commit",
|
||||
"title",
|
||||
"task",
|
||||
];
|
||||
|
||||
/** Alias for ModelRoleInfo - used for both built-in and custom roles */
|
||||
export type RoleInfo = ModelRoleInfo;
|
||||
|
||||
/**
|
||||
@@ -37,7 +49,7 @@ export type RoleInfo = ModelRoleInfo;
|
||||
* entries across settings.
|
||||
*/
|
||||
export function getKnownRoleIds(settings: Settings): string[] {
|
||||
const roles = [...MODEL_ROLE_IDS] as string[];
|
||||
const roles = MODEL_ROLE_IDS.filter(role => !MODEL_ROLES[role as ModelRole]?.hidden) as string[];
|
||||
const seen = new Set<string>(roles);
|
||||
const addRole = (role: string) => {
|
||||
if (seen.has(role)) return;
|
||||
@@ -65,6 +77,7 @@ export function getRoleInfo(role: string, settings: Settings): RoleInfo {
|
||||
tag: builtIn?.tag,
|
||||
name: configured.name || builtIn?.name || role,
|
||||
color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color,
|
||||
hidden: configured.hidden ?? builtIn?.hidden,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,8 @@ type SettingDef =
|
||||
export interface ModelTagDef {
|
||||
name: string;
|
||||
color?: string;
|
||||
/** If true, the role is functional but not shown in the model selector UI. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelTagsSettings {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Primary provider for OMP native configs. Supports all capabilities.
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import { getManagedSkillsDir, MANAGED_SKILLS_PROVIDER_ID } from "../autolearn/managed-skills";
|
||||
import { registerProvider } from "../capability";
|
||||
@@ -61,7 +61,9 @@ async function getConfigDirs(ctx: LoadContext): Promise<Array<{ dir: string; lev
|
||||
if (projectDir) {
|
||||
result.push({ dir: projectDir, level: "project" });
|
||||
}
|
||||
const userDir = await ifNonEmptyDir(ctx.home, PATHS.userAgent);
|
||||
// Native user config is profile-scoped: getAgentDir() points at the active
|
||||
// profile's agent dir (~/.omp/profiles/<name>/agent), like sessions and MCP.
|
||||
const userDir = await ifNonEmptyDir(getAgentDir());
|
||||
if (userDir) {
|
||||
result.push({ dir: userDir, level: "user" });
|
||||
}
|
||||
@@ -178,6 +180,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
redirectUri?: string;
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
| undefined,
|
||||
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
|
||||
@@ -187,11 +190,14 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
return result;
|
||||
};
|
||||
|
||||
// User scope tracks the active profile via getAgentDir() (not ctx.home), so it
|
||||
// stays in sync with getMCPConfigPath("user") and the /mcp config writer.
|
||||
const userAgentDir = getAgentDir();
|
||||
const paths = [
|
||||
{ path: path.join(ctx.cwd, PATHS.projectDir, "mcp.json"), level: "project" as const },
|
||||
{ path: path.join(ctx.cwd, PATHS.projectDir, ".mcp.json"), level: "project" as const },
|
||||
{ path: path.join(ctx.home, PATHS.userAgent, "mcp.json"), level: "user" as const },
|
||||
{ path: path.join(ctx.home, PATHS.userAgent, ".mcp.json"), level: "user" as const },
|
||||
{ path: path.join(userAgentDir, "mcp.json"), level: "user" as const },
|
||||
{ path: path.join(userAgentDir, ".mcp.json"), level: "user" as const },
|
||||
];
|
||||
|
||||
const contents = await Promise.allSettled(
|
||||
@@ -226,7 +232,7 @@ registerProvider<MCPServer>(mcpCapability.id, {
|
||||
async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
|
||||
const items: SystemPrompt[] = [];
|
||||
|
||||
const userPath = path.join(ctx.home, PATHS.userAgent, "SYSTEM.md");
|
||||
const userPath = path.join(getAgentDir(), "SYSTEM.md");
|
||||
const userContent = await readFile(userPath);
|
||||
if (userContent) {
|
||||
items.push({
|
||||
@@ -277,7 +283,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
|
||||
// User-level scan from ~/.omp/agent/skills/
|
||||
const userScan = scanSkillsFromDir(ctx, {
|
||||
dir: path.join(ctx.home, PATHS.userAgent, "skills"),
|
||||
dir: path.join(getAgentDir(), "skills"),
|
||||
providerId: PROVIDER_ID,
|
||||
level: "user",
|
||||
requireDescription: true,
|
||||
@@ -297,7 +303,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
const MANAGED_SKILLS_PRIORITY = 5;
|
||||
async function loadManagedSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
return scanSkillsFromDir(ctx, {
|
||||
dir: getManagedSkillsDir(ctx.home),
|
||||
dir: getManagedSkillsDir(),
|
||||
providerId: MANAGED_SKILLS_PROVIDER_ID,
|
||||
level: "user",
|
||||
requireDescription: true,
|
||||
@@ -373,7 +379,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
||||
// the current turn so they keep hold across long conversations".
|
||||
// User scope: ~/.omp/agent/RULES.md
|
||||
// Project scope: nearest .omp/RULES.md walking up from cwd to repoRoot
|
||||
const userRulesFile = path.join(ctx.home, PATHS.userAgent, "RULES.md");
|
||||
const userRulesFile = path.join(getAgentDir(), "RULES.md");
|
||||
const userRule = await loadStickyRulesFile(userRulesFile, "user");
|
||||
if (userRule) items.push(userRule);
|
||||
|
||||
@@ -890,7 +896,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
||||
const items: ContextFile[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const userPath = path.join(ctx.home, PATHS.userAgent, "AGENTS.md");
|
||||
const userPath = path.join(getAgentDir(), "AGENTS.md");
|
||||
const userContent = await readFile(userPath);
|
||||
if (userContent) {
|
||||
items.push({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { FileType, glob } from "@oh-my-pi/pi-natives";
|
||||
import {
|
||||
CONFIG_DIR_NAME,
|
||||
getAgentDir,
|
||||
getConfigDirName,
|
||||
getPluginsDir,
|
||||
getProjectDir,
|
||||
@@ -86,6 +87,11 @@ export type SourceId = keyof typeof SOURCE_PATHS;
|
||||
* Get user-level path for a source.
|
||||
*/
|
||||
export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null {
|
||||
// Native user config is profile-scoped via getAgentDir() (the active profile's
|
||||
// agent dir), matching builtin.ts and getMCPConfigPath("user"). External tools
|
||||
// (~/.claude, ~/.gemini, …) are intentionally not profile-scoped, so they keep
|
||||
// resolving against ctx.home below.
|
||||
if (source === "native") return path.join(getAgentDir(), subpath);
|
||||
const paths = SOURCE_PATHS[source];
|
||||
if (!paths.userAgent) return null;
|
||||
return path.join(ctx.home, paths.userAgent, subpath);
|
||||
@@ -569,6 +575,25 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
|
||||
const indexFiles = [...globIndexFiles, ...linkedFiles.indexFiles];
|
||||
const packageJsonFiles = [...globPackageJsonFiles, ...linkedFiles.packageJsonFiles];
|
||||
|
||||
// The native glob walker runs with follow_links=false, so a symlinked extension
|
||||
// directory is yielded as a Symlink entry but never descended into: its inner
|
||||
// index.{ts,js}/package.json are invisible to the `*/...` patterns above.
|
||||
// Detect top-level symlinked directories and synthesize the equivalent subdir
|
||||
// matches so the resolution below treats them like real directories. Symlinked
|
||||
// *files* already match, because the native file-type filter resolves a
|
||||
// symlink's target type for File filters.
|
||||
const topLevelEntries = await readDirEntries(dir);
|
||||
for (const entry of topLevelEntries) {
|
||||
if (!entry.isSymbolicLink()) continue;
|
||||
// readDirEntries follows the symlink: a link to a file/dangling link yields [].
|
||||
const subEntries = await readDirEntries(path.join(dir, entry.name));
|
||||
const hasEntry = (name: string): boolean =>
|
||||
subEntries.some(e => e.name === name && (e.isFile() || e.isSymbolicLink()));
|
||||
if (hasEntry("package.json")) packageJsonFiles.push({ path: `${entry.name}/package.json` });
|
||||
if (hasEntry("index.ts")) indexFiles.push({ path: `${entry.name}/index.ts` });
|
||||
else if (hasEntry("index.js")) indexFiles.push({ path: `${entry.name}/index.js` });
|
||||
}
|
||||
|
||||
// Process direct files
|
||||
for (const match of directFiles) {
|
||||
if (match.path.includes("/")) continue;
|
||||
|
||||
@@ -46,6 +46,7 @@ interface MCPConfigFile {
|
||||
redirectUri?: string;
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
||||
import { readDirEntries, readFile } from "../capability/fs";
|
||||
import type { LoadContext } from "../capability/types";
|
||||
import { getEnabledPlugins } from "../extensibility/plugins/loader";
|
||||
@@ -82,7 +82,7 @@ interface ScopeDirs {
|
||||
function scopeDirs(ctx: LoadContext): ScopeDirs {
|
||||
return {
|
||||
project: path.join(ctx.cwd, ".omp"),
|
||||
user: path.join(ctx.home, ".omp", "agent"),
|
||||
user: getAgentDir(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -89,3 +89,46 @@ export async function recordFileSnapshot(
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading line-number prefix the hashline/summary/grep formatters stamp on
|
||||
* every displayed body line: `NN:` or a collapsed summary `NN-MM:` from `read`,
|
||||
* optionally preceded by a grep `*` (match) / space (context) marker from
|
||||
* `search`/`ast-grep`. Anchored at line start, so source content after the
|
||||
* colon never matches.
|
||||
*/
|
||||
const HASHLINE_LINE_PREFIX = /^[ *]?(\d+)(?:-(\d+))?:/;
|
||||
|
||||
/**
|
||||
* The 1-indexed file lines a hashline-formatted body actually displayed.
|
||||
* Single `NN:` rows contribute that line; a collapsed summary `NN-MM:` row
|
||||
* (a `{ .. }` brace pair) contributes only its boundary lines `NN` and `MM` —
|
||||
* the elided interior was never shown, so editing inside it must be rejected.
|
||||
*/
|
||||
export function parseSeenLinesFromHashlineBody(body: string): number[] {
|
||||
const seen: number[] = [];
|
||||
for (const row of body.split("\n")) {
|
||||
const match = HASHLINE_LINE_PREFIX.exec(row);
|
||||
if (!match) continue;
|
||||
seen.push(Number(match[1]));
|
||||
if (match[2] !== undefined) seen.push(Number(match[2]));
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the lines a read displayed to the snapshot it minted, so the patcher
|
||||
* can reject edits anchored on lines the model never saw. Best-effort: a no-op
|
||||
* when the body has no numbered rows or the snapshot already aged out. `tag`
|
||||
* must be the tag returned when this exact content was recorded.
|
||||
*/
|
||||
export function recordSeenLinesFromBody(
|
||||
session: FileSnapshotStoreOwner,
|
||||
absolutePath: string,
|
||||
tag: string,
|
||||
body: string,
|
||||
): void {
|
||||
const seen = parseSeenLinesFromHashlineBody(body);
|
||||
if (seen.length === 0) return;
|
||||
getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, seen);
|
||||
}
|
||||
|
||||
@@ -679,13 +679,14 @@ describe("agent() through eval runtimes", () => {
|
||||
cost: 0,
|
||||
durationMs: i * 10,
|
||||
});
|
||||
await Bun.sleep(5);
|
||||
await Bun.sleep(40);
|
||||
}
|
||||
return singleResult(options, { output: "done" });
|
||||
});
|
||||
|
||||
const ops: string[] = [];
|
||||
using idle = new IdleTimeout(40);
|
||||
// Timing invariant (keep, do not re-tighten): total mock work (20*40ms = 800ms) > idle window (250ms) > scheduling jitter (~tens of ms).
|
||||
using idle = new IdleTimeout(250);
|
||||
const result = await runEvalAgent(
|
||||
{ prompt: "investigate" },
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils/temp";
|
||||
import { createHelpers, type HelperContext } from "../js/shared/helpers";
|
||||
|
||||
/**
|
||||
|
||||
@@ -342,9 +342,63 @@ export class JsRuntime {
|
||||
// Prelude assigns console bridge + short aliases (`read`, `write`, `tool`, `display`, ...)
|
||||
// onto globalThis. Must run after helpers are in place.
|
||||
indirectEval(JAVASCRIPT_PRELUDE_SOURCE);
|
||||
RUN_HOOK_RESOLVERS.add(() => this.#als.getStore()?.hooks);
|
||||
patchStdioOnce();
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolvers for each live runtime's active-run hooks (one per JsRuntime instance). */
|
||||
const RUN_HOOK_RESOLVERS = new Set<() => RuntimeHooks | undefined>();
|
||||
|
||||
/** Streams whose `write` the runtime has already wrapped (patch-once guard). */
|
||||
const PATCHED_STDIO_STREAMS = new WeakSet<NodeJS.WriteStream>();
|
||||
|
||||
/** Hooks for whichever registered runtime currently has an active run, if any. */
|
||||
function activeRunHooks(): RuntimeHooks | undefined {
|
||||
for (const resolve of RUN_HOOK_RESOLVERS) {
|
||||
const hooks = resolve();
|
||||
if (hooks) return hooks;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `process.stdout` / `process.stderr` `write` exactly once per process so
|
||||
* user `process.stdout.write(...)` lands in the active run's text sink. Models
|
||||
* reach for it out of Node habit, but `process` is intentionally the host
|
||||
* worker's real object (see {@link JsRuntime} `#install`), so unrouted writes
|
||||
* escape to the worker's own stdio and never reach the cell — and `write()`
|
||||
* returns a boolean, so a cell ending in `process.stdout.write("x")` captured
|
||||
* `true` while losing the text. Patch only the `write` method (never replace
|
||||
* `process`), preserve exact bytes (no trailing newline), and fall through to
|
||||
* the real stream when no run is active so the worker's own logging is intact.
|
||||
*/
|
||||
function patchStdioOnce(): void {
|
||||
const streams: NodeJS.WriteStream[] = [process.stdout, process.stderr];
|
||||
for (const stream of streams) {
|
||||
if (!stream || PATCHED_STDIO_STREAMS.has(stream)) continue;
|
||||
PATCHED_STDIO_STREAMS.add(stream);
|
||||
const original = stream.write.bind(stream) as (...args: unknown[]) => boolean;
|
||||
const routed = (chunk: unknown, encoding?: unknown, callback?: unknown): boolean => {
|
||||
const hooks = activeRunHooks();
|
||||
if (!hooks) return original(chunk, encoding, callback);
|
||||
const cb = typeof encoding === "function" ? encoding : callback;
|
||||
const enc = typeof encoding === "string" ? (encoding as BufferEncoding) : undefined;
|
||||
hooks.onText(chunkToString(chunk, enc));
|
||||
if (typeof cb === "function") (cb as (error?: Error | null) => void)();
|
||||
return true;
|
||||
};
|
||||
stream.write = routed as unknown as typeof stream.write;
|
||||
}
|
||||
}
|
||||
|
||||
/** Coerce a `write()` chunk to text, honoring an explicit encoding for byte chunks. */
|
||||
function chunkToString(chunk: unknown, encoding?: BufferEncoding): string {
|
||||
if (typeof chunk === "string") return chunk;
|
||||
if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString(encoding ?? "utf8");
|
||||
return String(chunk);
|
||||
}
|
||||
|
||||
function formatConsoleArgs(args: unknown[]): string {
|
||||
return args
|
||||
.map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
|
||||
|
||||
@@ -71,6 +71,28 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
|
||||
extensionHandlerTimeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated cap for `session_shutdown` handlers. The generic 30s budget is
|
||||
* appropriate for events extensions can observe (e.g. `session_start`,
|
||||
* `before_provider_request`), but `session_shutdown` is fire-and-forget
|
||||
* teardown — extensions receive no result and the user has already asked to
|
||||
* leave. A hung handler (e.g. an extension waiting on a stuck IPC pipe to a
|
||||
* companion app) MUST NOT hold Ctrl+C / `/exit` hostage for the full window.
|
||||
* See issue #2600.
|
||||
*/
|
||||
export const SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS = 2_000;
|
||||
let sessionShutdownHandlerTimeoutMs = SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS;
|
||||
|
||||
export function testSetSessionShutdownHandlerTimeoutMs(timeoutMs: number): void {
|
||||
sessionShutdownHandlerTimeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/** Per-event handler budget. Defaults to the generic cap; `session_shutdown`
|
||||
* uses its own short cap so teardown stays prompt. */
|
||||
function handlerTimeoutForEvent(eventType: string): number {
|
||||
return eventType === "session_shutdown" ? sessionShutdownHandlerTimeoutMs : extensionHandlerTimeoutMs;
|
||||
}
|
||||
|
||||
const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
|
||||
|
||||
const MAX_PENDING_CREDENTIAL_DISABLED = 32;
|
||||
@@ -576,7 +598,7 @@ export class ExtensionRunner {
|
||||
event,
|
||||
ctx,
|
||||
ext,
|
||||
extensionHandlerTimeoutMs,
|
||||
handlerTimeoutForEvent(event.type),
|
||||
);
|
||||
|
||||
if (this.#isSessionBeforeEvent(event) && handlerResult) {
|
||||
|
||||
@@ -356,7 +356,10 @@ export class GoalRuntime {
|
||||
this.#wallClock.lastAccountedAt += wallSeconds * 1000;
|
||||
}
|
||||
|
||||
await this.#commitState(state, { persist: "goal" });
|
||||
// Persisting wall-clock-only accounting on every tool event bloats /goal sessions with full
|
||||
// objective snapshots. Keep the in-memory/UI state fresh, but persist only token/budget changes.
|
||||
const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited;
|
||||
await this.#commitState(state, { persist: shouldPersistUsage ? "goal" : undefined });
|
||||
|
||||
if (state.goal.status !== "budget-limited") {
|
||||
this.#budgetReportedFor = undefined;
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
unsubscribeFromResources,
|
||||
} from "./client";
|
||||
import { loadAllMCPConfigs, validateServerConfig } from "./config";
|
||||
import { refreshMCPOAuthToken } from "./oauth-flow";
|
||||
import {
|
||||
lookupMcpOAuthCredential,
|
||||
type MCPOAuthCredentialLookup,
|
||||
selectMcpOAuthRefreshMaterial,
|
||||
} from "./oauth-credentials";
|
||||
import { type MCPStoredOAuthCredential, refreshMCPOAuthToken } from "./oauth-flow";
|
||||
import type { MCPToolDetails } from "./tool-bridge";
|
||||
import { DeferredMCPTool, MCPTool } from "./tool-bridge";
|
||||
import type { MCPToolCache } from "./tool-cache";
|
||||
@@ -400,9 +405,15 @@ export class MCPManager {
|
||||
}
|
||||
|
||||
// Wire auth refresh for HTTP transports so 401s trigger token refresh.
|
||||
if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
|
||||
// Gate on a resolvable managed credential, not on the auth block:
|
||||
// definition-only configs (url-keyed fallback) get Bearer injection
|
||||
// too and need the same mid-session refresh hook.
|
||||
if (
|
||||
connection.transport instanceof HttpTransport &&
|
||||
lookupMcpOAuthCredential(this.#authStorage, config)
|
||||
) {
|
||||
connection.transport.onAuthError = async () => {
|
||||
const refreshed = await this.#resolveAuthConfig(config, true);
|
||||
const refreshed = await this.#resolveAuthConfig(config, { forceRefresh: true });
|
||||
if (refreshed.type === "http" || refreshed.type === "sse") {
|
||||
return refreshed.headers ?? null;
|
||||
}
|
||||
@@ -673,9 +684,11 @@ export class MCPManager {
|
||||
|
||||
/**
|
||||
* Resolve auth and shell-command substitutions in config before connecting.
|
||||
* Pass `oauth: false` to skip OAuth credential injection (used by reauth's
|
||||
* unauthenticated probe, which must observe the server's bare 401).
|
||||
*/
|
||||
async prepareConfig(config: MCPServerConfig): Promise<MCPServerConfig> {
|
||||
return this.#resolveAuthConfig(config);
|
||||
async prepareConfig(config: MCPServerConfig, options?: { oauth?: boolean }): Promise<MCPServerConfig> {
|
||||
return this.#resolveAuthConfig(config, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -925,9 +938,10 @@ export class MCPManager {
|
||||
this.#connections.set(name, connection);
|
||||
|
||||
// Wire auth refresh for HTTP transports, and reconnect for any transport.
|
||||
if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
|
||||
// Same gate as connectServers: any resolvable managed credential.
|
||||
if (connection.transport instanceof HttpTransport && lookupMcpOAuthCredential(this.#authStorage, config)) {
|
||||
connection.transport.onAuthError = async () => {
|
||||
const refreshed = await this.#resolveAuthConfig(config, true);
|
||||
const refreshed = await this.#resolveAuthConfig(config, { forceRefresh: true });
|
||||
if (refreshed.type === "http" || refreshed.type === "sse") {
|
||||
return refreshed.headers ?? null;
|
||||
}
|
||||
@@ -1169,78 +1183,101 @@ export class MCPManager {
|
||||
|
||||
/**
|
||||
* Resolve OAuth credentials and shell commands in config.
|
||||
* `oauth: false` skips credential injection (reauth's unauthenticated probe);
|
||||
* `forceRefresh` bypasses the expiry buffer (401/403 auth-error hook).
|
||||
*/
|
||||
async #resolveAuthConfig(config: MCPServerConfig, forceRefresh = false): Promise<MCPServerConfig> {
|
||||
async #resolveAuthConfig(
|
||||
config: MCPServerConfig,
|
||||
opts?: { forceRefresh?: boolean; oauth?: boolean },
|
||||
): Promise<MCPServerConfig> {
|
||||
let resolved: MCPServerConfig = { ...config };
|
||||
|
||||
const auth = config.auth;
|
||||
if (auth?.type === "oauth" && auth.credentialId && this.#authStorage) {
|
||||
const credentialId = auth.credentialId;
|
||||
const lookup: MCPOAuthCredentialLookup | undefined =
|
||||
opts?.oauth !== false ? lookupMcpOAuthCredential(this.#authStorage, config) : undefined;
|
||||
if (lookup && this.#authStorage) {
|
||||
const { credentialId } = lookup;
|
||||
try {
|
||||
let credential = this.#authStorage.get(credentialId);
|
||||
if (credential?.type === "oauth") {
|
||||
// Proactive refresh: 5-minute buffer before expiry
|
||||
// Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
|
||||
const REFRESH_BUFFER_MS = 5 * 60_000;
|
||||
const shouldRefresh =
|
||||
forceRefresh || (credential.expires && Date.now() >= credential.expires - REFRESH_BUFFER_MS);
|
||||
if (shouldRefresh && credential.refresh && auth.tokenUrl) {
|
||||
const resource =
|
||||
auth.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
|
||||
try {
|
||||
const refreshed = await refreshMCPOAuthToken(
|
||||
auth.tokenUrl,
|
||||
credential.refresh,
|
||||
auth.clientId,
|
||||
auth.clientSecret,
|
||||
resource,
|
||||
);
|
||||
const refreshedCredential = { type: "oauth" as const, ...refreshed };
|
||||
await this.#authStorage.set(credentialId, refreshedCredential);
|
||||
credential = refreshedCredential;
|
||||
} catch (refreshError) {
|
||||
const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
|
||||
if (isDefinitiveOAuthFailure(errorMsg)) {
|
||||
// `invalid_grant` / `invalid_token` / 401 from the token endpoint means
|
||||
// the server has retired this credential — keeping the stale access
|
||||
// token would just re-fail with 401 on every MCP request and leave a
|
||||
// poisoned row in agent.db that survives restarts. Drop it now so the
|
||||
// next connect attempt surfaces a clean "needs reauth" failure and
|
||||
// the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
|
||||
// to forget the server entirely).
|
||||
logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
|
||||
credentialId,
|
||||
error: errorMsg,
|
||||
});
|
||||
await this.#authStorage.remove(credentialId);
|
||||
credential = undefined;
|
||||
} else {
|
||||
logger.warn("MCP OAuth refresh failed, using existing token", {
|
||||
credentialId,
|
||||
error: refreshError,
|
||||
});
|
||||
}
|
||||
let credential: MCPStoredOAuthCredential | undefined = lookup.credential;
|
||||
// Refresh material comes from ONE source: the credential's embedded
|
||||
// fields (written atomically with the tokens they minted — tokenUrl
|
||||
// always present) or, for legacy rows that predate embedding, the
|
||||
// config auth block. Never mix the two: a shared file's auth block
|
||||
// can belong to another profile, whose client the grant is NOT
|
||||
// bound to.
|
||||
const material = selectMcpOAuthRefreshMaterial(credential, auth);
|
||||
const tokenUrl = material?.tokenUrl;
|
||||
const clientId = material?.clientId;
|
||||
const clientSecret = material?.clientSecret;
|
||||
const resource =
|
||||
material?.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
|
||||
// Proactive refresh: 5-minute buffer before expiry
|
||||
// Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
|
||||
const REFRESH_BUFFER_MS = 5 * 60_000;
|
||||
const shouldRefresh =
|
||||
opts?.forceRefresh || (credential.expires && Date.now() >= credential.expires - REFRESH_BUFFER_MS);
|
||||
if (shouldRefresh && credential.refresh && tokenUrl) {
|
||||
try {
|
||||
const refreshed = await refreshMCPOAuthToken(
|
||||
tokenUrl,
|
||||
credential.refresh,
|
||||
clientId,
|
||||
clientSecret,
|
||||
resource,
|
||||
);
|
||||
// Spread the old credential first so embedded refresh material survives rotation.
|
||||
const refreshedCredential: MCPStoredOAuthCredential = {
|
||||
...credential,
|
||||
...refreshed,
|
||||
tokenUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
resource,
|
||||
};
|
||||
await this.#authStorage.set(credentialId, refreshedCredential);
|
||||
credential = refreshedCredential;
|
||||
} catch (refreshError) {
|
||||
const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
|
||||
if (isDefinitiveOAuthFailure(errorMsg)) {
|
||||
// `invalid_grant` / `invalid_token` / 401 from the token endpoint means
|
||||
// the server has retired this credential — keeping the stale access
|
||||
// token would just re-fail with 401 on every MCP request and leave a
|
||||
// poisoned row in agent.db that survives restarts. Drop it now so the
|
||||
// next connect attempt surfaces a clean "needs reauth" failure and
|
||||
// the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
|
||||
// to forget the server entirely).
|
||||
logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
|
||||
credentialId,
|
||||
error: errorMsg,
|
||||
});
|
||||
await this.#authStorage.remove(credentialId);
|
||||
credential = undefined;
|
||||
} else {
|
||||
logger.warn("MCP OAuth refresh failed, using existing token", {
|
||||
credentialId,
|
||||
error: refreshError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (credential?.type === "oauth") {
|
||||
if (resolved.type === "http" || resolved.type === "sse") {
|
||||
resolved = {
|
||||
...resolved,
|
||||
headers: {
|
||||
...resolved.headers,
|
||||
Authorization: `Bearer ${credential.access}`,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
resolved = {
|
||||
...resolved,
|
||||
env: {
|
||||
...resolved.env,
|
||||
OAUTH_ACCESS_TOKEN: credential.access,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (credential) {
|
||||
if (resolved.type === "http" || resolved.type === "sse") {
|
||||
resolved = {
|
||||
...resolved,
|
||||
headers: {
|
||||
...resolved.headers,
|
||||
Authorization: `Bearer ${credential.access}`,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
resolved = {
|
||||
...resolved,
|
||||
env: {
|
||||
...resolved.env,
|
||||
OAUTH_ACCESS_TOKEN: credential.access,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
|
||||
import { expandEnvVarsDeep } from "../discovery/helpers";
|
||||
import type { AuthStorage } from "../session/auth-storage";
|
||||
import {
|
||||
isManagedMCPOAuthCredentialId,
|
||||
type MCPStoredOAuthCredential,
|
||||
mcpOAuthCredentialId,
|
||||
mcpOAuthCredentialProfile,
|
||||
} from "./oauth-flow";
|
||||
import type { MCPAuthConfig, MCPServerConfig } from "./types";
|
||||
|
||||
export interface MCPOAuthCredentialLookup {
|
||||
credentialId: string;
|
||||
credential: MCPStoredOAuthCredential;
|
||||
}
|
||||
|
||||
export type MCPOAuthRefreshMaterial = MCPStoredOAuthCredential | MCPAuthConfig | undefined;
|
||||
|
||||
export function mcpOAuthCredentialIdsForServerUrl(serverUrl: string | undefined): string[] {
|
||||
if (!serverUrl) return [];
|
||||
const ids: string[] = [];
|
||||
for (const url of [expandEnvVarsDeep(serverUrl), serverUrl]) {
|
||||
const id = mcpOAuthCredentialId(url);
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function hasMcpAuthorizationHeader(config: MCPServerConfig): boolean {
|
||||
if (config.type !== "http" && config.type !== "sse") return false;
|
||||
return Object.keys(config.headers ?? {}).some(header => header.toLowerCase() === "authorization");
|
||||
}
|
||||
|
||||
export function lookupMcpOAuthCredentialForServer(
|
||||
authStorage: AuthStorage | null | undefined,
|
||||
auth: MCPAuthConfig | undefined,
|
||||
serverUrl: string | undefined,
|
||||
options: { allowUrlKeyedFallback?: boolean } = {},
|
||||
): MCPOAuthCredentialLookup | undefined {
|
||||
if (!authStorage) return undefined;
|
||||
if (auth && auth.type !== "oauth") return undefined;
|
||||
const urlKeyedCredentialIds = mcpOAuthCredentialIdsForServerUrl(serverUrl);
|
||||
if (
|
||||
auth?.credentialId &&
|
||||
(!auth.credentialId.startsWith("mcp_oauth:profile:") || urlKeyedCredentialIds.includes(auth.credentialId))
|
||||
) {
|
||||
const credential = authStorage.get(auth.credentialId);
|
||||
if (credential?.type === "oauth") {
|
||||
return { credentialId: auth.credentialId, credential };
|
||||
}
|
||||
}
|
||||
if (options.allowUrlKeyedFallback === false) return undefined;
|
||||
for (const credentialId of urlKeyedCredentialIds) {
|
||||
const credential = authStorage.get(credentialId);
|
||||
if (credential?.type === "oauth") {
|
||||
return { credentialId, credential };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function lookupMcpOAuthCredential(
|
||||
authStorage: AuthStorage | null | undefined,
|
||||
config: MCPServerConfig,
|
||||
): MCPOAuthCredentialLookup | undefined {
|
||||
const auth = config.auth;
|
||||
if (config.type !== "http" && config.type !== "sse") {
|
||||
return lookupMcpOAuthCredentialForServer(authStorage, auth, undefined);
|
||||
}
|
||||
if (hasMcpAuthorizationHeader(config)) {
|
||||
return lookupMcpOAuthCredentialForServer(authStorage, auth, config.url, { allowUrlKeyedFallback: false });
|
||||
}
|
||||
return lookupMcpOAuthCredentialForServer(authStorage, auth, config.url);
|
||||
}
|
||||
|
||||
export function selectMcpOAuthRefreshMaterial(
|
||||
credential: MCPStoredOAuthCredential,
|
||||
auth: MCPAuthConfig | undefined,
|
||||
): MCPOAuthRefreshMaterial {
|
||||
return credential.tokenUrl ? credential : auth;
|
||||
}
|
||||
|
||||
export async function removeManagedMcpOAuthCredential(
|
||||
authStorage: AuthStorage,
|
||||
credentialId: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!isManagedMCPOAuthCredentialId(credentialId)) return false;
|
||||
const scopedProfile = mcpOAuthCredentialProfile(credentialId);
|
||||
if (scopedProfile !== undefined && scopedProfile !== (getActiveProfile() ?? "default")) return false;
|
||||
if (authStorage.get(credentialId)?.type !== "oauth") return false;
|
||||
await authStorage.remove(credentialId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function removeManagedMcpOAuthCredentials(
|
||||
authStorage: AuthStorage,
|
||||
credentialIds: readonly (string | undefined)[],
|
||||
): Promise<boolean> {
|
||||
let removed = false;
|
||||
for (const credentialId of credentialIds) {
|
||||
removed = (await removeManagedMcpOAuthCredential(authStorage, credentialId)) || removed;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -9,6 +9,60 @@ import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/oauth/callback-se
|
||||
import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/oauth/callback-server";
|
||||
import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
|
||||
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
|
||||
import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
|
||||
import type { OAuthCredential } from "../session/auth-storage";
|
||||
|
||||
/** Credential-id prefix for OMP-managed MCP OAuth credentials keyed by profile and server URL. */
|
||||
const MCP_OAUTH_URL_CREDENTIAL_PREFIX = "mcp_oauth:";
|
||||
|
||||
/** Credential-id prefix for profile-scoped MCP OAuth credentials (`mcp_oauth:profile:<profile>:<serverUrl>`). */
|
||||
const MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX = `${MCP_OAUTH_URL_CREDENTIAL_PREFIX}profile:`;
|
||||
|
||||
/**
|
||||
* Deterministic credential id for an MCP server URL scoped to an OMP profile.
|
||||
*
|
||||
* Local profile stores are already separate, but auth-broker storage shares one
|
||||
* provider namespace across profiles. Including the profile in the provider key
|
||||
* keeps a shared project `mcp.json` definition from making profile B overwrite
|
||||
* or read profile A's OAuth row for the same server URL. The URL is used
|
||||
* verbatim (query string included) because it can carry tenant selectors such
|
||||
* as `?project_ref=`.
|
||||
*/
|
||||
export function mcpOAuthCredentialId(serverUrl: string, profile: string | undefined = getActiveProfile()): string {
|
||||
return `${MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX}${profile ?? "default"}:${serverUrl}`;
|
||||
}
|
||||
|
||||
/** Whether a credential id was minted by OMP's MCP OAuth flows (either era). */
|
||||
export function isManagedMCPOAuthCredentialId(credentialId: string | undefined): credentialId is string {
|
||||
return (
|
||||
!!credentialId &&
|
||||
(credentialId.startsWith("mcp_oauth_") || credentialId.startsWith(MCP_OAUTH_URL_CREDENTIAL_PREFIX))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile segment of a profile-scoped `mcp_oauth:profile:<profile>:<serverUrl>`
|
||||
* credential id, or `undefined` for legacy non-profile-scoped managed ids
|
||||
* (`mcp_oauth:<url>`, `mcp_oauth_<rand>`). The server URL itself contains `:`
|
||||
* and `/`, so only the segment between the prefix and the FIRST subsequent `:`
|
||||
* is the profile; everything after it is the URL.
|
||||
*/
|
||||
export function mcpOAuthCredentialProfile(credentialId: string): string | undefined {
|
||||
if (!credentialId.startsWith(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX)) return undefined;
|
||||
const separator = credentialId.indexOf(":", MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length);
|
||||
return separator === -1 ? undefined : credentialId.slice(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length, separator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored MCP OAuth credential. Refresh material is embedded so token refresh
|
||||
* works without any `auth` block persisted in (possibly shared) config files.
|
||||
*/
|
||||
export interface MCPStoredOAuthCredential extends OAuthCredential {
|
||||
tokenUrl?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 3000;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
@@ -126,6 +180,15 @@ export interface MCPOAuthConfig {
|
||||
clientSecret?: string;
|
||||
/** OAuth scopes (space-separated) */
|
||||
scopes?: string;
|
||||
/**
|
||||
* `prompt` parameter for the authorization request. Defaults to `"consent"`
|
||||
* so the provider always shows its authorize screen instead of silently
|
||||
* re-approving the browser's current session — without it, reauthorizing to
|
||||
* switch accounts/workspaces is impossible once a session cookie exists
|
||||
* (RFC 6749 §3.1 requires servers to ignore the param when unsupported).
|
||||
* Set to `""` to omit the parameter entirely.
|
||||
*/
|
||||
prompt?: string;
|
||||
/** Exact redirect URI to advertise to the provider */
|
||||
redirectUri?: string;
|
||||
/** Custom callback port (default: 3000) */
|
||||
@@ -202,6 +265,10 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
||||
if (this.config.scopes && !params.get("scope")) {
|
||||
params.set("scope", this.config.scopes);
|
||||
}
|
||||
const prompt = this.config.prompt ?? "consent";
|
||||
if (prompt && !params.get("prompt")) {
|
||||
params.set("prompt", prompt);
|
||||
}
|
||||
const existingResource = params.get("resource")?.trim();
|
||||
if (existingResource) {
|
||||
this.#resource = resolveResourceUri(existingResource);
|
||||
|
||||
@@ -74,6 +74,8 @@ interface MCPServerConfigBase {
|
||||
redirectUri?: string;
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
/** `prompt` param for the authorization request (default "consent"; "" to omit) */
|
||||
prompt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
||||
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
||||
import type { AgentSession } from "../../session/agent-session";
|
||||
import {
|
||||
BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
|
||||
type CustomMessage,
|
||||
isSilentAbort,
|
||||
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
|
||||
@@ -45,6 +46,7 @@ import type { ObservableSession, SessionObserverRegistry } from "../session-obse
|
||||
import { getEditorTheme, theme } from "../theme/theme";
|
||||
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
||||
import { AssistantMessageComponent } from "./assistant-message";
|
||||
import { createBackgroundTanDispatchBlock } from "./background-tan-message";
|
||||
import { BashExecutionComponent } from "./bash-execution";
|
||||
import { BranchSummaryMessageComponent } from "./branch-summary-message";
|
||||
import { CollabPromptMessageComponent } from "./collab-prompt-message";
|
||||
@@ -1215,6 +1217,10 @@ export class AgentHubOverlayComponent extends Container {
|
||||
this.#chatLog.addChild(card);
|
||||
return;
|
||||
}
|
||||
if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
|
||||
this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
|
||||
return;
|
||||
}
|
||||
const handoffComponent = createHandoffSummaryMessageComponent(
|
||||
message as CustomMessage<unknown>,
|
||||
this.#chatExpanded,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import type { BackgroundTanDispatchDetails, CustomMessage } from "../../session/messages";
|
||||
import { replaceTabs } from "../../tools/render-utils";
|
||||
import { theme } from "../theme/theme";
|
||||
import { TranscriptBlock } from "./transcript-container";
|
||||
|
||||
const TAN_WORK_PREVIEW_LENGTH = 56;
|
||||
|
||||
function previewWork(work: string): string {
|
||||
const singleLine = replaceTabs(work).trim().replace(/\s+/g, " ");
|
||||
if (singleLine.length <= TAN_WORK_PREVIEW_LENGTH) return singleLine;
|
||||
return `${singleLine.slice(0, TAN_WORK_PREVIEW_LENGTH - 1)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line transcript pill for a `/tan` background-dispatch breadcrumb,
|
||||
* styled as a sibling of the "Background job completed" line. The full
|
||||
* system-notice content (the persisted `content`) is for the model only — the
|
||||
* user sees one compact line, not the raw `<system-notice>` block.
|
||||
*/
|
||||
export function createBackgroundTanDispatchBlock(message: CustomMessage<unknown>): TranscriptBlock {
|
||||
const details = (message as CustomMessage<Partial<BackgroundTanDispatchDetails>>).details;
|
||||
const jobId = details?.jobId ?? "unknown";
|
||||
const work = details?.work ? previewWork(details.work) : undefined;
|
||||
const line = [
|
||||
theme.fg("muted", `${theme.icon.output} Tangent dispatched`),
|
||||
theme.fg("dim", "[task]"),
|
||||
theme.fg("accent", jobId),
|
||||
work ? theme.fg("dim", `${theme.format.dash} ${work}`) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const block = new TranscriptBlock();
|
||||
block.addChild(new Text(line, 1, 0));
|
||||
return block;
|
||||
}
|
||||
@@ -49,14 +49,19 @@ type WizardStep =
|
||||
|
||||
/**
|
||||
* Result of the wizard's OAuth callback. `credentialId` is mandatory;
|
||||
* `clientId`/`clientSecret` are populated when the OAuth provider performed
|
||||
* dynamic client registration (or when the caller pre-supplied them) so the
|
||||
* wizard can fold them into the final `mcp.json` entry for refresh.
|
||||
* `clientId` is populated when the OAuth provider performed dynamic client
|
||||
* registration (or when the caller pre-supplied it) so the wizard can fold it
|
||||
* into the final `mcp.json` entry. Refresh material (including any DCR client
|
||||
* secret) is embedded in the stored credential, never written to config files.
|
||||
*/
|
||||
export interface MCPAddWizardOAuthResult {
|
||||
credentialId: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
interface MCPAddWizardOAuthOptions {
|
||||
serverUrl?: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
@@ -125,7 +130,7 @@ export class MCPAddWizard extends Container {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scopes: string,
|
||||
resource?: string,
|
||||
options?: MCPAddWizardOAuthOptions,
|
||||
) => Promise<MCPAddWizardOAuthResult>)
|
||||
| null = null;
|
||||
#onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
|
||||
@@ -140,7 +145,7 @@ export class MCPAddWizard extends Container {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scopes: string,
|
||||
resource?: string,
|
||||
options?: MCPAddWizardOAuthOptions,
|
||||
) => Promise<MCPAddWizardOAuthResult>,
|
||||
onTestConnection?: (config: MCPServerConfig) => Promise<void>,
|
||||
onRender?: () => void,
|
||||
@@ -1157,14 +1162,16 @@ export class MCPAddWizard extends Container {
|
||||
this.#state.oauthClientId,
|
||||
this.#state.oauthClientSecret,
|
||||
this.#state.oauthScopes,
|
||||
oauthResource || undefined,
|
||||
{
|
||||
serverUrl: this.#state.url || undefined,
|
||||
resource: oauthResource || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// Store credential ID + any dynamically-registered client credentials,
|
||||
// so the final mcp.json entry persists everything needed for refresh.
|
||||
// Store credential ID + any dynamically-registered client id. DCR client
|
||||
// secrets stay embedded in the stored credential, never in mcp.json.
|
||||
this.#state.oauthCredentialId = oauthResult.credentialId;
|
||||
if (oauthResult.clientId) this.#state.oauthClientId = oauthResult.clientId;
|
||||
if (oauthResult.clientSecret) this.#state.oauthClientSecret = oauthResult.clientSecret;
|
||||
this.#state.oauthResource = oauthResult.resource ?? oauthResource;
|
||||
|
||||
// Show success message
|
||||
|
||||
@@ -936,7 +936,8 @@ export class ModelSelectorComponent extends Container {
|
||||
// Build role badges. Solid badges are configured; outlined badges are auto-selected defaults.
|
||||
const roleBadgeTokens: string[] = [];
|
||||
for (const role of MODEL_ROLE_IDS) {
|
||||
const { tag, color } = getRoleInfo(role, this.#settings);
|
||||
const { tag, color, hidden } = getRoleInfo(role, this.#settings);
|
||||
if (hidden) continue;
|
||||
const assigned = this.#roles[role];
|
||||
if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
|
||||
|
||||
@@ -1053,6 +1054,10 @@ export class ModelSelectorComponent extends Container {
|
||||
this.#menuStep = "role";
|
||||
this.#menuSelectedRole = null;
|
||||
this.#menuSelectedIndex = 0;
|
||||
// Collapse the model list while the action/thinking menu is open so the
|
||||
// menu owns the full viewport instead of stacking below a now-irrelevant
|
||||
// (and often off-screen) list.
|
||||
this.#listContainer.clear();
|
||||
this.#updateMenu();
|
||||
}
|
||||
|
||||
@@ -1061,6 +1066,8 @@ export class ModelSelectorComponent extends Container {
|
||||
this.#menuStep = "role";
|
||||
this.#menuSelectedRole = null;
|
||||
this.#menuContainer.clear();
|
||||
// Restore the model list that #openMenu collapsed.
|
||||
this.#updateList();
|
||||
}
|
||||
|
||||
#updateMenu(): void {
|
||||
@@ -1088,11 +1095,21 @@ export class ModelSelectorComponent extends Container {
|
||||
? ` Thinking for: ${selectedRoleName} (${selectedItem.id})`
|
||||
: ` Action for: ${selectedItem.id}`;
|
||||
const hintText = showingThinking ? " Enter: confirm Esc: back" : " Enter: continue Esc: cancel";
|
||||
const menuWidth = Math.max(
|
||||
// Window the option list so a long action/thinking menu scrolls inside the
|
||||
// viewport instead of running off the bottom of the screen.
|
||||
const maxVisible = this.#getMenuVisibleCount(optionLines.length);
|
||||
const needsScroll = optionLines.length > maxVisible;
|
||||
const startIndex = needsScroll
|
||||
? Math.max(0, Math.min(this.#menuSelectedIndex - Math.floor(maxVisible / 2), optionLines.length - maxVisible))
|
||||
: 0;
|
||||
const endIndex = needsScroll ? startIndex + maxVisible : optionLines.length;
|
||||
const contentWidth = Math.max(
|
||||
visibleWidth(headerText),
|
||||
visibleWidth(hintText),
|
||||
...optionLines.map(line => visibleWidth(line)),
|
||||
);
|
||||
// Reserve one column for the scrollbar when the list overflows.
|
||||
const menuWidth = contentWidth + (needsScroll ? 1 : 0);
|
||||
|
||||
this.#menuContainer.addChild(new Spacer(1));
|
||||
this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
|
||||
@@ -1109,12 +1126,28 @@ export class ModelSelectorComponent extends Container {
|
||||
}
|
||||
this.#menuContainer.addChild(new Spacer(1));
|
||||
|
||||
for (let i = 0; i < optionLines.length; i++) {
|
||||
const visibleRows: string[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const lineText = optionLines[i];
|
||||
if (!lineText) continue;
|
||||
if (lineText === undefined) continue;
|
||||
const isSelected = i === this.#menuSelectedIndex;
|
||||
const line = isSelected ? theme.fg("accent", lineText) : theme.fg("muted", lineText);
|
||||
this.#menuContainer.addChild(new Text(line, 0, 0));
|
||||
visibleRows.push(isSelected ? theme.fg("accent", lineText) : theme.fg("muted", lineText));
|
||||
}
|
||||
if (needsScroll) {
|
||||
const sv = new ScrollView(visibleRows, {
|
||||
height: visibleRows.length,
|
||||
scrollbar: "auto",
|
||||
totalRows: optionLines.length,
|
||||
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
||||
});
|
||||
sv.setScrollOffset(startIndex);
|
||||
for (const row of sv.render(menuWidth)) {
|
||||
this.#menuContainer.addChild(new Text(row, 0, 0));
|
||||
}
|
||||
} else {
|
||||
for (const row of visibleRows) {
|
||||
this.#menuContainer.addChild(new Text(row, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
this.#menuContainer.addChild(new Spacer(1));
|
||||
@@ -1122,6 +1155,17 @@ export class ModelSelectorComponent extends Container {
|
||||
this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
|
||||
}
|
||||
|
||||
#getMenuVisibleCount(optionCount: number): number {
|
||||
// Rows the selector chrome and the menu's own header/hint/borders/spacers
|
||||
// consume, leaving the remainder of the viewport for the scrollable option
|
||||
// window. Without a known terminal height (e.g. tests) show every option.
|
||||
const MENU_CHROME_ROWS = 19;
|
||||
const MIN_VISIBLE_OPTIONS = 4;
|
||||
const terminalRows = this.#tui.terminal?.rows ?? 0;
|
||||
if (!Number.isFinite(terminalRows) || terminalRows <= 0) return optionCount;
|
||||
return Math.max(MIN_VISIBLE_OPTIONS, Math.min(optionCount, terminalRows - MENU_CHROME_ROWS));
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (this.#isMenuOpen) {
|
||||
this.#handleMenuInput(keyData);
|
||||
|
||||
@@ -785,18 +785,11 @@ export class InputController {
|
||||
}
|
||||
|
||||
handleCtrlC(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.ctx.lastSigintTime < 500) {
|
||||
void this.ctx.shutdown();
|
||||
} else {
|
||||
this.ctx.clearEditor();
|
||||
this.ctx.lastSigintTime = now;
|
||||
}
|
||||
// Sync-flush the session JSONL so in-flight writes survive a hard exit.
|
||||
// The TUI consumes Ctrl+C as a key event in raw mode, so postmortem's
|
||||
// process-level SIGINT handler never fires. The second press still
|
||||
// funnels through shutdown() which awaits its own async flush — the
|
||||
// sync flush here is a superset that also covers the first-press case.
|
||||
// process-level SIGINT handler never fires. shutdown() awaits its own
|
||||
// async flush — this sync pass is a superset that also covers the
|
||||
// first-press case and the hard-abort path below.
|
||||
try {
|
||||
this.ctx.sessionManager.flushSync();
|
||||
} catch (err) {
|
||||
@@ -804,6 +797,24 @@ export class InputController {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
// Hard-abort: a Ctrl+C arriving while shutdown() is already running
|
||||
// means the user has waited long enough for whatever teardown step is
|
||||
// stuck (typically an extension's session_shutdown handler hanging on
|
||||
// IPC). The 2s session_shutdown cap (see runner.ts) already bounds the
|
||||
// common case; this is the defense-in-depth ladder for everything
|
||||
// else. See issue #2600.
|
||||
if (this.ctx.isShuttingDown) {
|
||||
process.exit(130); // 128 + SIGINT
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.ctx.lastSigintTime < 500) {
|
||||
void this.ctx.shutdown();
|
||||
} else {
|
||||
this.ctx.clearEditor();
|
||||
this.ctx.lastSigintTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
handleCtrlD(): void {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as path from "node:path";
|
||||
import { type Component, replaceTabs, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import type { SourceMeta } from "../../capability/types";
|
||||
import { expandEnvVarsDeep } from "../../discovery/helpers";
|
||||
import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../mcp";
|
||||
import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
|
||||
import {
|
||||
@@ -17,7 +18,13 @@ import {
|
||||
setServerDisabled,
|
||||
updateMCPServer,
|
||||
} from "../../mcp/config-writer";
|
||||
import { MCPOAuthFlow } from "../../mcp/oauth-flow";
|
||||
import {
|
||||
lookupMcpOAuthCredentialForServer,
|
||||
mcpOAuthCredentialIdsForServerUrl,
|
||||
removeManagedMcpOAuthCredential,
|
||||
removeManagedMcpOAuthCredentials,
|
||||
} from "../../mcp/oauth-credentials";
|
||||
import { MCPOAuthFlow, type MCPStoredOAuthCredential, mcpOAuthCredentialId } from "../../mcp/oauth-flow";
|
||||
import {
|
||||
clearSmitheryApiKey,
|
||||
createSmitheryCliAuthSession,
|
||||
@@ -34,7 +41,6 @@ import {
|
||||
toConfigName,
|
||||
} from "../../mcp/smithery-registry";
|
||||
import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
||||
import type { OAuthCredential } from "../../session/auth-storage";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { urlHyperlinkAlways } from "../../tui";
|
||||
import { openPath } from "../../utils/open";
|
||||
@@ -116,17 +122,16 @@ class McpConnectingBlock extends ChatBlock {
|
||||
/**
|
||||
* Outcome of {@link MCPCommandController}'s OAuth handler.
|
||||
*
|
||||
* `clientId`/`clientSecret` are populated when the OAuth provider required (or
|
||||
* accepted) dynamic client registration; callers MUST persist them alongside
|
||||
* `credentialId` so subsequent token refreshes and reauthorizations can reuse
|
||||
* the same registered client. Both are also set when the caller pre-supplied a
|
||||
* client id via the wizard or `oauth.clientId` in `mcp.json`, in which case the
|
||||
* write-back is a no-op.
|
||||
* `credentialId` is deterministic per server URL when the URL was supplied, so
|
||||
* every profile resolves its own credential row under the same id. Refresh
|
||||
* material (token URL, client id/secret) is embedded in the stored credential;
|
||||
* the returned `clientId` may be folded into `mcp.json` for pre-auth reuse.
|
||||
* DCR-issued client secrets stay embedded in the stored credential and are
|
||||
* deliberately not surfaced here, so they cannot leak into config files.
|
||||
*/
|
||||
interface OAuthFlowResult {
|
||||
credentialId: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
@@ -490,38 +495,28 @@ export class MCPCommandController {
|
||||
}
|
||||
|
||||
try {
|
||||
const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
|
||||
const oauthResource = oauth.resource ?? finalConfig.url;
|
||||
const oauthResult = await this.#handleOAuthFlow(
|
||||
oauth.authorizationUrl,
|
||||
oauth.tokenUrl,
|
||||
oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
|
||||
oauthClientSecret,
|
||||
finalConfig.oauth?.clientSecret ?? "",
|
||||
oauth.scopes ?? "",
|
||||
finalConfig.oauth?.callbackPort,
|
||||
finalConfig.oauth?.callbackPath,
|
||||
finalConfig.oauth?.redirectUri,
|
||||
oauthResource,
|
||||
{
|
||||
callbackPort: finalConfig.oauth?.callbackPort,
|
||||
callbackPath: finalConfig.oauth?.callbackPath,
|
||||
redirectUri: finalConfig.oauth?.redirectUri,
|
||||
prompt: finalConfig.oauth?.prompt,
|
||||
serverUrl: finalConfig.url,
|
||||
resource: oauthResource,
|
||||
},
|
||||
);
|
||||
const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId;
|
||||
const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret;
|
||||
const persistedResource = oauthResult.resource ?? oauthResource;
|
||||
finalConfig = {
|
||||
...finalConfig,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
credentialId: oauthResult.credentialId,
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
resource: persistedResource,
|
||||
clientId: persistedClientId,
|
||||
clientSecret: persistedClientSecret,
|
||||
},
|
||||
oauth: {
|
||||
...finalConfig.oauth,
|
||||
clientId: persistedClientId ?? finalConfig.oauth?.clientId,
|
||||
clientSecret: persistedClientSecret ?? finalConfig.oauth?.clientSecret,
|
||||
},
|
||||
};
|
||||
finalConfig = this.#persistOAuthResult(finalConfig, oauthResult, {
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
resource: oauthResource,
|
||||
clientId: oauth.clientId,
|
||||
userClientSecret: finalConfig.oauth?.clientSecret,
|
||||
});
|
||||
} catch (oauthError) {
|
||||
this.ctx.showError(
|
||||
`OAuth flow failed for "${parsed.initialName}": ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`,
|
||||
@@ -553,25 +548,8 @@ export class MCPCommandController {
|
||||
done();
|
||||
this.#handleWizardCancel();
|
||||
},
|
||||
async (
|
||||
authUrl: string,
|
||||
tokenUrl: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scopes: string,
|
||||
resource?: string,
|
||||
) => {
|
||||
return await this.#handleOAuthFlow(
|
||||
authUrl,
|
||||
tokenUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scopes,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
resource,
|
||||
);
|
||||
async (authUrl: string, tokenUrl: string, clientId: string, clientSecret: string, scopes: string, options) => {
|
||||
return await this.#handleOAuthFlow(authUrl, tokenUrl, clientId, clientSecret, scopes, options);
|
||||
},
|
||||
async (config: MCPServerConfig) => {
|
||||
return await this.#handleTestConnection(config);
|
||||
@@ -598,10 +576,14 @@ export class MCPCommandController {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scopes: string,
|
||||
callbackPort?: number,
|
||||
callbackPath?: string,
|
||||
redirectUri?: string,
|
||||
resource?: string,
|
||||
opts?: {
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
redirectUri?: string;
|
||||
prompt?: string;
|
||||
serverUrl?: string;
|
||||
resource?: string;
|
||||
},
|
||||
): Promise<OAuthFlowResult> {
|
||||
const authStorage = this.ctx.session.modelRegistry.authStorage;
|
||||
let parsedAuthUrl: URL;
|
||||
@@ -637,10 +619,11 @@ export class MCPCommandController {
|
||||
clientId: resolvedClientId,
|
||||
clientSecret: resolvedClientSecret,
|
||||
scopes: scopes || undefined,
|
||||
redirectUri,
|
||||
callbackPort,
|
||||
callbackPath,
|
||||
resource,
|
||||
prompt: opts?.prompt,
|
||||
redirectUri: opts?.redirectUri,
|
||||
callbackPort: opts?.callbackPort,
|
||||
callbackPath: opts?.callbackPath,
|
||||
resource: opts?.resource,
|
||||
},
|
||||
{
|
||||
onAuth: (info: { url: string; instructions?: string }) => {
|
||||
@@ -712,22 +695,29 @@ export class MCPCommandController {
|
||||
new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0),
|
||||
]);
|
||||
|
||||
// Generate a unique credential ID
|
||||
const credentialId = `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
// Deterministic per-URL id: every profile resolves its own credential row
|
||||
// under the same key, so shared project configs stay profile-isolated.
|
||||
// Random fallback only for flows that never knew the server URL.
|
||||
const credentialId = opts?.serverUrl
|
||||
? mcpOAuthCredentialId(opts.serverUrl)
|
||||
: `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
// Store credentials in auth storage
|
||||
const oauthCredential: OAuthCredential = {
|
||||
// Embed refresh material so the credential is self-contained: token
|
||||
// refresh must work for configs that carry no auth block at all.
|
||||
const oauthCredential: MCPStoredOAuthCredential = {
|
||||
type: "oauth",
|
||||
...credentials,
|
||||
tokenUrl,
|
||||
clientId: flow.resolvedClientId ?? resolvedClientId,
|
||||
clientSecret: flow.registeredClientSecret ?? resolvedClientSecret,
|
||||
resource: flow.resource,
|
||||
};
|
||||
|
||||
// Store under a synthetic provider name
|
||||
await authStorage.set(credentialId, oauthCredential);
|
||||
|
||||
return {
|
||||
credentialId,
|
||||
clientId: flow.resolvedClientId,
|
||||
clientSecret: flow.registeredClientSecret,
|
||||
resource: flow.resource,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -750,20 +740,52 @@ export class MCPCommandController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a completed OAuth flow back into a server config. Owns the
|
||||
* persistence policy in one place: the auth block records the credential
|
||||
* pointer plus refresh material, the oauth block echoes the client id for
|
||||
* pre-auth reuse, and only a user-supplied client secret is ever written —
|
||||
* DCR-issued secrets stay embedded in the stored credential so they cannot
|
||||
* leak into (possibly shared/committed) config files.
|
||||
*/
|
||||
#persistOAuthResult(
|
||||
config: MCPServerConfig,
|
||||
result: OAuthFlowResult,
|
||||
opts: { tokenUrl: string; resource?: string; clientId?: string; userClientSecret?: string },
|
||||
): MCPServerConfig {
|
||||
const clientId = result.clientId ?? opts.clientId ?? config.oauth?.clientId;
|
||||
const resource = result.resource ?? opts.resource ?? config.auth?.resource;
|
||||
return {
|
||||
...config,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
credentialId: result.credentialId,
|
||||
tokenUrl: opts.tokenUrl,
|
||||
clientId,
|
||||
clientSecret: opts.userClientSecret,
|
||||
resource,
|
||||
},
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
clientId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to an MCP server.
|
||||
* Throws an error if connection fails (used for auto-detection).
|
||||
*/
|
||||
async #handleTestConnection(config: MCPServerConfig): Promise<void> {
|
||||
async #handleTestConnection(config: MCPServerConfig, options?: { oauth?: boolean }): Promise<void> {
|
||||
// Create temporary connection using a test name
|
||||
const testName = `test_${Date.now()}`;
|
||||
let resolvedConfig: MCPServerConfig;
|
||||
if (this.ctx.mcpManager) {
|
||||
resolvedConfig = await this.ctx.mcpManager.prepareConfig(config);
|
||||
resolvedConfig = await this.ctx.mcpManager.prepareConfig(config, options);
|
||||
} else {
|
||||
const tempManager = new MCPManager(getProjectDir());
|
||||
tempManager.setAuthStorage(this.ctx.session.modelRegistry.authStorage);
|
||||
resolvedConfig = await tempManager.prepareConfig(config);
|
||||
resolvedConfig = await tempManager.prepareConfig(config, options);
|
||||
}
|
||||
|
||||
const connection = await connectToServer(testName, resolvedConfig);
|
||||
@@ -850,11 +872,6 @@ export class MCPCommandController {
|
||||
};
|
||||
}
|
||||
|
||||
async #removeManagedOAuthCredential(credentialId: string | undefined): Promise<void> {
|
||||
if (!credentialId?.startsWith("mcp_oauth_")) return;
|
||||
await this.ctx.session.modelRegistry.authStorage.remove(credentialId);
|
||||
}
|
||||
|
||||
#stripOAuthAuth(config: MCPServerConfig): MCPServerConfig {
|
||||
const next = { ...config } as MCPServerConfig & { auth?: MCPAuthConfig };
|
||||
delete next.auth;
|
||||
@@ -868,11 +885,26 @@ export class MCPCommandController {
|
||||
scopes?: string;
|
||||
resource?: string;
|
||||
}> {
|
||||
// Stdio servers manage credentials inside the child process; OMP's OAuth
|
||||
// flow only applies to http/sse transports. Without this guard the
|
||||
// unauthenticated preflight below spawns the child, which happily reuses
|
||||
// its own cached tokens (e.g. mcp-remote's machine-wide ~/.mcp-auth) and
|
||||
// produces the misleading "reauthorization is not required".
|
||||
if (config.type !== "http" && config.type !== "sse") {
|
||||
const remoteUrl = config.args?.find(arg => /^https?:\/\//.test(arg));
|
||||
const httpHint = `{ "type": "http", "url": ${JSON.stringify(remoteUrl ?? "<remote url>")} }`;
|
||||
const usesMcpRemote = [config.command, ...(config.args ?? [])].some(part => part?.includes("mcp-remote"));
|
||||
throw new Error(
|
||||
usesMcpRemote
|
||||
? `this server proxies OAuth through mcp-remote, which caches tokens machine-wide in ~/.mcp-auth (shared across every OMP profile). Clear ~/.mcp-auth to force a fresh login, or replace the proxy with ${httpHint} so OMP manages OAuth per profile.`
|
||||
: `stdio servers manage their own credentials, so OMP has no OAuth to reauthorize. If the service supports OAuth over HTTP, configure it as ${httpHint} instead.`,
|
||||
);
|
||||
}
|
||||
// First test if server actually needs auth by connecting without OAuth
|
||||
let connectionSucceeded = false;
|
||||
let connectionError: Error | undefined;
|
||||
try {
|
||||
await this.#handleTestConnection(this.#stripOAuthAuth(config));
|
||||
await this.#handleTestConnection(this.#stripOAuthAuth(config), { oauth: false });
|
||||
connectionSucceeded = true;
|
||||
} catch (error) {
|
||||
connectionError = error as Error;
|
||||
@@ -1433,12 +1465,34 @@ export class MCPCommandController {
|
||||
}
|
||||
|
||||
const currentAuth = (found.config as MCPServerConfig & { auth?: MCPAuthConfig }).auth;
|
||||
if (found.discovered && currentAuth?.type !== "oauth") {
|
||||
this.#showMessage(["", theme.fg("muted", `No stored OAuth auth to remove for "${name}".`), ""].join("\n"));
|
||||
return;
|
||||
}
|
||||
const authStorage = this.ctx.session.modelRegistry.authStorage;
|
||||
if (currentAuth?.type === "oauth") {
|
||||
await this.#removeManagedOAuthCredential(currentAuth.credentialId);
|
||||
await removeManagedMcpOAuthCredential(authStorage, currentAuth.credentialId);
|
||||
}
|
||||
// Also drop this profile's url-keyed binding so the server is truly
|
||||
// signed out even when the config carries no auth block. Runtime
|
||||
// discovery expands `${...}` URL values before MCPManager looks up the
|
||||
// deterministic credential row, so unauth must clear that same key.
|
||||
let removedUrlKeyedCredential = false;
|
||||
if ((found.config.type === "http" || found.config.type === "sse") && found.config.url) {
|
||||
removedUrlKeyedCredential = await removeManagedMcpOAuthCredentials(
|
||||
authStorage,
|
||||
mcpOAuthCredentialIdsForServerUrl(found.config.url),
|
||||
);
|
||||
}
|
||||
|
||||
if (found.discovered && currentAuth?.type !== "oauth") {
|
||||
if (!removedUrlKeyedCredential) {
|
||||
this.#showMessage(
|
||||
["", theme.fg("muted", `No stored OAuth auth to remove for "${name}".`), ""].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.#reloadMCP();
|
||||
this.#showMessage(
|
||||
["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = this.#stripOAuthAuth(found.config);
|
||||
@@ -1472,52 +1526,70 @@ export class MCPCommandController {
|
||||
}
|
||||
|
||||
const currentAuth = (found.config as MCPServerConfig & { auth?: MCPAuthConfig }).auth;
|
||||
if (currentAuth?.type === "oauth") {
|
||||
await this.#removeManagedOAuthCredential(currentAuth.credentialId);
|
||||
}
|
||||
|
||||
const authStorage = this.ctx.session.modelRegistry.authStorage;
|
||||
const baseConfig = this.#stripOAuthAuth(found.config);
|
||||
const oauth = await this.#resolveOAuthEndpointsFromServer(baseConfig);
|
||||
const oauthClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret ?? "";
|
||||
const runtimeBaseConfig = expandEnvVarsDeep(baseConfig);
|
||||
// Resolve endpoints first: this fails fast for stdio transports and
|
||||
// probes http/sse with { oauth: false }, so nothing destructive has
|
||||
// happened yet if the server turns out not to need (or support) OAuth.
|
||||
// Use the same env-expanded config shape runtime discovery passes to
|
||||
// MCPManager; the raw file value may contain `${...}` placeholders.
|
||||
const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig);
|
||||
const serverUrl =
|
||||
runtimeBaseConfig.type === "http" || runtimeBaseConfig.type === "sse" ? runtimeBaseConfig.url : undefined;
|
||||
// A user-supplied client secret may live in either block (the wizard
|
||||
// writes it to auth.clientSecret); DCR secrets are embedded in the
|
||||
// stored credential and never echoed back into config files.
|
||||
const configuredClientId = found.config.oauth?.clientId ?? currentAuth?.clientId;
|
||||
const existingCredential = lookupMcpOAuthCredentialForServer(authStorage, currentAuth, serverUrl)?.credential;
|
||||
const flowClientId = oauth.clientId ?? configuredClientId ?? existingCredential?.clientId ?? "";
|
||||
const storedClientSecret =
|
||||
existingCredential?.clientId === flowClientId ? existingCredential.clientSecret : undefined;
|
||||
const userClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret;
|
||||
const flowClientSecret = userClientSecret ?? storedClientSecret ?? "";
|
||||
|
||||
this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
|
||||
|
||||
const currentAuthResource = currentAuth?.resource ? expandEnvVarsDeep(currentAuth.resource) : undefined;
|
||||
const oauthResource =
|
||||
oauth.resource ?? currentAuth?.resource ?? ("url" in baseConfig ? baseConfig.url : undefined);
|
||||
oauth.resource ?? currentAuthResource ?? ("url" in runtimeBaseConfig ? runtimeBaseConfig.url : undefined);
|
||||
|
||||
const oauthResult = await this.#handleOAuthFlow(
|
||||
oauth.authorizationUrl,
|
||||
oauth.tokenUrl,
|
||||
oauth.clientId ?? found.config.oauth?.clientId ?? "",
|
||||
oauthClientSecret,
|
||||
flowClientId,
|
||||
flowClientSecret,
|
||||
oauth.scopes ?? "",
|
||||
found.config.oauth?.callbackPort,
|
||||
found.config.oauth?.callbackPath,
|
||||
found.config.oauth?.redirectUri,
|
||||
oauthResource,
|
||||
{
|
||||
callbackPort: found.config.oauth?.callbackPort,
|
||||
callbackPath: found.config.oauth?.callbackPath,
|
||||
redirectUri: found.config.oauth?.redirectUri,
|
||||
prompt: found.config.oauth?.prompt,
|
||||
serverUrl,
|
||||
resource: oauthResource,
|
||||
},
|
||||
);
|
||||
|
||||
const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId;
|
||||
const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined);
|
||||
const persistedResource = oauthResult.resource ?? oauthResource;
|
||||
// The flow overwrote (or minted) this profile's row; a superseded
|
||||
// pointer row from the legacy random-id era is now orphaned. GC only
|
||||
// after success so cancelling the browser step leaves the previous
|
||||
// session signed in.
|
||||
if (currentAuth?.type === "oauth" && currentAuth.credentialId !== oauthResult.credentialId) {
|
||||
await removeManagedMcpOAuthCredential(authStorage, currentAuth.credentialId);
|
||||
}
|
||||
|
||||
const updated: MCPServerConfig = {
|
||||
...baseConfig,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
credentialId: oauthResult.credentialId,
|
||||
// Definition-only entries resolve through the url-keyed binding alone;
|
||||
// skip the write-back so a committed project mcp.json stays clean.
|
||||
const urlKeyedId = serverUrl ? mcpOAuthCredentialId(serverUrl) : undefined;
|
||||
if (currentAuth || oauthResult.credentialId !== urlKeyedId) {
|
||||
const updated = this.#persistOAuthResult(baseConfig, oauthResult, {
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
resource: persistedResource,
|
||||
clientId: persistedClientId,
|
||||
clientSecret: persistedClientSecret,
|
||||
},
|
||||
oauth: {
|
||||
...found.config.oauth,
|
||||
clientId: persistedClientId ?? found.config.oauth?.clientId,
|
||||
clientSecret: persistedClientSecret ?? found.config.oauth?.clientSecret,
|
||||
},
|
||||
};
|
||||
await updateMCPServer(found.filePath, name, updated);
|
||||
clientId: oauth.clientId,
|
||||
userClientSecret,
|
||||
resource: oauthResource,
|
||||
});
|
||||
await updateMCPServer(found.filePath, name, updated);
|
||||
}
|
||||
await this.#reloadMCP();
|
||||
const state = await this.#waitForServerConnectionWithAnimation(name);
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
||||
import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" };
|
||||
import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
||||
import * as sdk from "../../sdk";
|
||||
import type { AgentSession } from "../../session/agent-session";
|
||||
import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE } from "../../session/messages";
|
||||
import { SessionManager } from "../../session/session-manager";
|
||||
import { createMCPProxyTools, createSubagentSettings } from "../../task/executor";
|
||||
import type { InteractiveModeContext } from "../types";
|
||||
@@ -44,10 +46,6 @@ export class TanCommandController {
|
||||
}
|
||||
|
||||
const session = this.ctx.session;
|
||||
if (session.isStreaming) {
|
||||
this.ctx.showWarning("Wait for the current response to finish or abort it before using /tan.");
|
||||
return;
|
||||
}
|
||||
|
||||
const model = session.model;
|
||||
if (!model) {
|
||||
@@ -84,19 +82,18 @@ export class TanCommandController {
|
||||
const enableLsp = this.ctx.settings.get("task.enableLsp") !== false;
|
||||
const agentRegistry = AgentRegistry.global();
|
||||
const cloneId = `Tan-${Snowflake.next()}`;
|
||||
const cloneFile = path.join(sessionDir, `${cloneId}.jsonl`);
|
||||
const label = `/tan ${previewWork(trimmedWork)}`;
|
||||
|
||||
await this.ctx.sessionManager.ensureOnDisk();
|
||||
await this.ctx.sessionManager.flush();
|
||||
|
||||
let cloneFile = "";
|
||||
let jobId = "";
|
||||
try {
|
||||
const cloneManager = await SessionManager.forkFrom(parentFile, cwd, sessionDir, undefined, {
|
||||
suppressBreadcrumb: true,
|
||||
sessionFile: cloneFile,
|
||||
});
|
||||
cloneFile = cloneManager.getSessionFile() ?? "";
|
||||
if (!cloneFile) throw new Error("Forked session did not create a session file.");
|
||||
|
||||
jobId = manager.register(
|
||||
"task",
|
||||
@@ -145,7 +142,21 @@ export class TanCommandController {
|
||||
signal.removeEventListener("abort", abortClone);
|
||||
}
|
||||
} finally {
|
||||
await clone?.dispose();
|
||||
// Keep the finished tan in the Agent Hub instead of unregistering it:
|
||||
// flip the ref to parked BEFORE dispose so the sdk dispose wrapper
|
||||
// skips its unregister, then null the disposed session so the hub
|
||||
// treats it as a transcript-only parked agent. An aborted tan is
|
||||
// terminal — let dispose unregister it.
|
||||
if (clone) {
|
||||
if (signal.aborted) {
|
||||
agentRegistry.setStatus(cloneId, "aborted");
|
||||
await clone.dispose();
|
||||
} else {
|
||||
agentRegistry.setStatus(cloneId, "parked");
|
||||
await clone.dispose();
|
||||
agentRegistry.detachSession(cloneId);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ ownerId },
|
||||
@@ -157,17 +168,22 @@ export class TanCommandController {
|
||||
}
|
||||
|
||||
const content = prompt.render(backgroundTanDispatchPrompt, { jobId, work: trimmedWork });
|
||||
// /tan is meant to run alongside an active session. While the parent turn is
|
||||
// still streaming, queue the dispatch breadcrumb for the next turn rather than
|
||||
// steering the in-flight response; when idle this same call appends + persists
|
||||
// the entry immediately (identical to omitting deliverAs).
|
||||
const wasStreaming = session.isStreaming;
|
||||
await session.sendCustomMessage(
|
||||
{
|
||||
customType: "background-tan-dispatch",
|
||||
customType: BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
|
||||
content,
|
||||
display: true,
|
||||
attribution: "user",
|
||||
details: { jobId, work: trimmedWork, sessionFile: cloneFile },
|
||||
},
|
||||
{ triggerTurn: false },
|
||||
{ triggerTurn: false, deliverAs: "nextTurn" },
|
||||
);
|
||||
this.ctx.rebuildChatFromMessages();
|
||||
if (!wasStreaming) this.ctx.rebuildChatFromMessages();
|
||||
this.ctx.showStatus(`Dispatched background tan ${jobId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +423,12 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
lastLeftTapTime = 0;
|
||||
shutdownRequested = false;
|
||||
#isShuttingDown = false;
|
||||
/** True once `shutdown()` has begun teardown. Surfaced to the input
|
||||
* controller so a Ctrl+C arriving while teardown is in flight can hard-
|
||||
* abort the remaining work instead of stacking another no-op call. */
|
||||
get isShuttingDown(): boolean {
|
||||
return this.#isShuttingDown;
|
||||
}
|
||||
hookSelector: HookSelectorComponent | undefined = undefined;
|
||||
hookInput: HookInputComponent | undefined = undefined;
|
||||
hookEditor: HookEditorComponent | undefined = undefined;
|
||||
|
||||
@@ -159,6 +159,9 @@ export interface InteractiveModeContext {
|
||||
lastEscapeTime: number;
|
||||
lastLeftTapTime: number;
|
||||
shutdownRequested: boolean;
|
||||
/** True once `shutdown()` has started. Read-only from the context;
|
||||
* controllers use this to skip work that races with teardown. */
|
||||
readonly isShuttingDown: boolean;
|
||||
hookSelector: HookSelectorComponent | undefined;
|
||||
hookInput: HookInputComponent | undefined;
|
||||
hookEditor: HookEditorComponent | undefined;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../coll
|
||||
import { settings } from "../../config/settings";
|
||||
import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
|
||||
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
||||
import { createBackgroundTanDispatchBlock } from "../../modes/components/background-tan-message";
|
||||
import { BashExecutionComponent } from "../../modes/components/bash-execution";
|
||||
import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
|
||||
import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message";
|
||||
@@ -33,6 +34,7 @@ import { materializeImageReferenceLinksSync } from "../../modes/image-references
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types";
|
||||
import {
|
||||
BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
|
||||
type CustomMessage,
|
||||
isSilentAbort,
|
||||
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
|
||||
@@ -238,6 +240,10 @@ export class UiHelpers {
|
||||
this.ctx.chatContainer.addChild(card);
|
||||
return [card];
|
||||
}
|
||||
if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
|
||||
this.ctx.chatContainer.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
|
||||
break;
|
||||
}
|
||||
const handoffComponent = createHandoffSummaryMessageComponent(
|
||||
message as CustomMessage<unknown>,
|
||||
this.ctx.toolOutputExpanded,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
Write a continuous, plain-prose technical explanation of how a relational database executes a SQL query: lexing and parsing, semantic analysis, logical plan construction, cost-based optimization, physical operator selection, and row-by-row execution through the iterator model.
|
||||
You are given a relational schema and a multi-way analytical query, and you must work out from first principles the execution plan a cost-based optimizer should choose. This is a hard estimation problem with a large search space, so think it all the way through before you settle on anything and reason your way to each number instead of answering from intuition. Do not recite how query optimization works in general — actually do the analysis for this query, deriving every estimate.
|
||||
|
||||
Schema and statistics: orders(id, customer_id, status, total) holds 50,000,000 rows with 5 distinct status values; customers(id, country, segment) holds 4,000,000 rows across 200 countries; line_items(order_id, product_id, qty) holds 300,000,000 rows; products(id, category, price) holds 80,000 rows across 600 categories. The query reports total revenue per product category for shipped orders placed by customers in one given country.
|
||||
|
||||
Reason step by step and keep going: estimate the selectivity and output cardinality of each predicate and each join, then enumerate every join order over the four tables and derive the cost of each under both nested-loop and hash-join operators, weigh index access against full scans for each table, decide where the aggregation belongs and whether a partial pre-aggregation or a semi-join reduction earns its keep, and account for a memory limit that forces a hash build side to spill to disk. Compute the number behind every decision before you commit to it; when you finish one candidate plan, move on to the next and derive its cost too, and choose a winner only after you have costed the whole field. Never assert a choice you have not justified with an estimate.
|
||||
|
||||
Form:
|
||||
- Plain paragraphs only: no headings, no lists, no code fences, no preamble.
|
||||
- Do not wrap up early or summarize; keep writing until you are cut off.
|
||||
- Plain paragraphs only: no headings, no lists, no code fences, no tables, no preamble.
|
||||
- Derive each estimate explicitly; state no conclusion you have not computed.
|
||||
- Do not wrap up early or summarize; keep reasoning until you are cut off.
|
||||
|
||||
Output only the explanation.
|
||||
Output only the analysis.
|
||||
|
||||
@@ -965,6 +965,13 @@ export class AgentSession {
|
||||
#activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
|
||||
// Todo completion reminder state
|
||||
#todoReminderCount = 0;
|
||||
/**
|
||||
* Set true after a todo reminder is appended; cleared when the agent makes any tool-level
|
||||
* progress (toolResult) or a new user prompt arrives. Suppresses follow-up reminders within
|
||||
* the same agent self-continuation chain so a text-only acknowledgement ("paused at your
|
||||
* instruction") does not drive 1/3 → 2/3 → 3/3 without user input.
|
||||
*/
|
||||
#todoReminderAwaitingProgress = false;
|
||||
#todoPhases: TodoPhase[] = [];
|
||||
#toolChoiceQueue = new ToolChoiceQueue();
|
||||
|
||||
@@ -1828,6 +1835,10 @@ export class AgentSession {
|
||||
isError?: boolean;
|
||||
content?: Array<TextContent | ImageContent>;
|
||||
};
|
||||
// A tool actually ran. Clear the post-reminder suppression: the agent did
|
||||
// productive work in response to the prior nudge, so the next text-only stop
|
||||
// is allowed to escalate to the next reminder if todos remain incomplete.
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
// Invalidate streaming edit cache when edit tool completes to prevent stale data
|
||||
if (toolName === "edit" && details?.path) {
|
||||
this.#invalidateFileCacheForPath(details.path);
|
||||
@@ -4750,6 +4761,7 @@ export class AgentSession {
|
||||
|
||||
// Reset todo reminder count on new user prompt
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
this.#emptyStopRetryCount = 0;
|
||||
|
||||
await this.#maybeRestoreRetryFallbackPrimary();
|
||||
@@ -5545,6 +5557,7 @@ export class AgentSession {
|
||||
);
|
||||
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
this.#planReferenceSent = false;
|
||||
this.#planReferencePath = "local://PLAN.md";
|
||||
this.#reconnectToAgent();
|
||||
@@ -6680,6 +6693,7 @@ export class AgentSession {
|
||||
this.#pendingNextTurnMessages = [];
|
||||
this.#scheduledHiddenNextTurnGeneration = undefined;
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
|
||||
// Inject the handoff document as a custom message
|
||||
const handoffContent = createHandoffContext(handoffText);
|
||||
@@ -7219,10 +7233,22 @@ export class AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress within a self-continuation chain: if the agent's last turn was driven by a
|
||||
// prior reminder (and the agent took no tool-level action since), do not re-ping.
|
||||
// The agent has already acknowledged; further escalation just wastes context and
|
||||
// pressures the agent into busy-work or destructive ops (issue #2590).
|
||||
if (this.#todoReminderAwaitingProgress) {
|
||||
logger.debug("Todo completion: prior reminder still awaiting agent action; staying silent", {
|
||||
attempt: this.#todoReminderCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const remindersEnabled = this.settings.get("todo.reminders");
|
||||
const todosEnabled = this.settings.get("todo.enabled");
|
||||
if (!remindersEnabled || !todosEnabled) {
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7235,6 +7261,7 @@ export class AgentSession {
|
||||
const phases = this.getTodoPhases();
|
||||
if (phases.length === 0) {
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7252,6 +7279,7 @@ export class AgentSession {
|
||||
const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
|
||||
if (incomplete.length === 0) {
|
||||
this.#todoReminderCount = 0;
|
||||
this.#todoReminderAwaitingProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7280,6 +7308,7 @@ export class AgentSession {
|
||||
maxAttempts: remindersMax,
|
||||
});
|
||||
|
||||
this.#todoReminderAwaitingProgress = true;
|
||||
// Inject reminder and continue the conversation
|
||||
this.agent.appendMessage({
|
||||
role: "developer",
|
||||
@@ -8466,7 +8495,7 @@ export class AgentSession {
|
||||
// src/http/h2_client/dispatch.zig)
|
||||
return (
|
||||
isUnexpectedSocketCloseMessage(errorMessage) ||
|
||||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i.test(
|
||||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i.test(
|
||||
errorMessage,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -34,6 +34,15 @@ import { formatOutputNotice } from "../tools/output-meta";
|
||||
|
||||
export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
|
||||
export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
|
||||
export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
|
||||
|
||||
/** Details persisted on a `/tan` background-dispatch breadcrumb. */
|
||||
export interface BackgroundTanDispatchDetails {
|
||||
jobId: string;
|
||||
work: string;
|
||||
/** Forked clone session file, named `<agentId>.jsonl`; the Agent Hub reads its transcript. */
|
||||
sessionFile: string;
|
||||
}
|
||||
|
||||
export interface SkillPromptDetails {
|
||||
name: string;
|
||||
|
||||
@@ -1483,13 +1483,18 @@ export class SessionManager {
|
||||
/**
|
||||
* Fork a session into the current project directory: copy history from another
|
||||
* session file while creating a fresh session file in this sessionDir.
|
||||
*
|
||||
* `options.sessionFile` pins the new session's file path (default: an
|
||||
* auto-named `<timestamp>_<id>.jsonl` in `sessionDir`). Callers that register
|
||||
* the fork as a named agent (e.g. `/tan`) pass `<agentId>.jsonl` so the
|
||||
* persisted-subagent scan keys the agent by the same id the live ref uses.
|
||||
*/
|
||||
static async forkFrom(
|
||||
sourcePath: string,
|
||||
cwd: string,
|
||||
sessionDir?: string,
|
||||
storage: SessionStorage = new FileSessionStorage(),
|
||||
options?: { suppressBreadcrumb?: boolean },
|
||||
options?: { suppressBreadcrumb?: boolean; sessionFile?: string },
|
||||
): Promise<SessionManager> {
|
||||
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
||||
const manager = new SessionManager(cwd, dir, true, storage);
|
||||
@@ -1501,7 +1506,7 @@ export class SessionManager {
|
||||
|
||||
const sourceHeader = sourceEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
||||
const history = sourceEntries.filter(entry => entry.type !== "session") as SessionEntry[];
|
||||
manager.#resetToNewSession({ parentSession: sourceHeader?.id });
|
||||
manager.#resetToNewSession({ parentSession: sourceHeader?.id }, options?.sessionFile);
|
||||
manager.#header.title = sourceHeader?.title;
|
||||
manager.#header.titleSource = sourceHeader?.titleSource;
|
||||
manager.#sessionName = manager.#header.title;
|
||||
|
||||
@@ -161,5 +161,9 @@ export function normalizeGeneratedTitle(value: string | null | undefined): strin
|
||||
.replace(/[.!?]$/, "")
|
||||
.trim();
|
||||
if (!title || title.toLowerCase() === NO_TITLE_SENTINEL) return null;
|
||||
return title;
|
||||
return titleCase(title);
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.replace(/\b\p{Ll}/gu, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { z } from "zod/v4";
|
||||
import { recordFileSnapshot } from "../edit/file-snapshot-store";
|
||||
import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
|
||||
@@ -270,6 +270,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
}
|
||||
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
||||
}
|
||||
if (hashContext?.tag) {
|
||||
const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
|
||||
recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
|
||||
}
|
||||
return { model: modelOut, display: displayOut };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const BUILTIN_TOOL_NAMES = [
|
||||
"read",
|
||||
"bash",
|
||||
"edit",
|
||||
"ast_grep",
|
||||
"ast_edit",
|
||||
"render_mermaid",
|
||||
"ask",
|
||||
"debug",
|
||||
"eval",
|
||||
"ssh",
|
||||
"github",
|
||||
"find",
|
||||
"search",
|
||||
"lsp",
|
||||
"inspect_image",
|
||||
"browser",
|
||||
"checkpoint",
|
||||
"rewind",
|
||||
"task",
|
||||
"job",
|
||||
"irc",
|
||||
"todo",
|
||||
"web_search",
|
||||
"search_tool_bm25",
|
||||
"write",
|
||||
"memory_edit",
|
||||
"retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"learn",
|
||||
"manage_skill",
|
||||
] as const;
|
||||
|
||||
export type BuiltinToolName = (typeof BUILTIN_TOOL_NAMES)[number];
|
||||
@@ -37,6 +37,7 @@ import { AstEditTool } from "./ast-edit";
|
||||
import { AstGrepTool } from "./ast-grep";
|
||||
import { BashTool } from "./bash";
|
||||
import { BrowserTool } from "./browser";
|
||||
import type { BuiltinToolName } from "./builtin-names";
|
||||
import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
|
||||
import { DebugTool } from "./debug";
|
||||
import { EvalTool } from "./eval";
|
||||
@@ -413,7 +414,7 @@ export function filterInitialToolsForDiscoveryAll(
|
||||
* Public callable factory map. External callers may invoke `BUILTIN_TOOLS.read(session)` or
|
||||
* `BUILTIN_TOOLS[name](session)` to construct a tool directly.
|
||||
*/
|
||||
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
||||
export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
|
||||
read: s => new ReadTool(s),
|
||||
bash: s => new BashTool(s),
|
||||
edit: s => new EditTool(s),
|
||||
@@ -455,7 +456,7 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
|
||||
goal: s => new GoalTool(s),
|
||||
};
|
||||
|
||||
export type ToolName = keyof typeof BUILTIN_TOOLS;
|
||||
export type ToolName = BuiltinToolName;
|
||||
|
||||
/**
|
||||
* Create tools from BUILTIN_TOOLS registry.
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
canonicalSnapshotKey,
|
||||
getFileSnapshotStore,
|
||||
recordFileSnapshot,
|
||||
recordSeenLinesFromBody,
|
||||
SNAPSHOT_MAX_BYTES,
|
||||
} from "../edit/file-snapshot-store";
|
||||
import { normalizeToLF } from "../edit/normalize";
|
||||
@@ -1356,6 +1357,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
if (shouldAddHashLines && outputText) {
|
||||
const tag = await recordFileSnapshot(this.session, absolutePath);
|
||||
if (tag) {
|
||||
recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
|
||||
outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
|
||||
}
|
||||
}
|
||||
@@ -2059,6 +2061,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
: undefined;
|
||||
const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
|
||||
const modelText = prependHashlineHeader(bodyText, summaryHashContext);
|
||||
if (summaryHashContext?.tag) {
|
||||
recordSeenLinesFromBody(this.session, absolutePath, summaryHashContext.tag, renderedSummary.text);
|
||||
}
|
||||
details = {
|
||||
displayContent: { text: renderedSummary.displayText, startLine: 1 },
|
||||
summary: {
|
||||
@@ -2354,6 +2359,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
sourcePath = absolutePath;
|
||||
}
|
||||
|
||||
if (hashContext?.tag) {
|
||||
recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
|
||||
}
|
||||
|
||||
if (capturedDisplayContent) {
|
||||
details.displayContent = capturedDisplayContent;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { z } from "zod/v4";
|
||||
import { recordFileSnapshot } from "../edit/file-snapshot-store";
|
||||
import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import type { LocalProtocolOptions } from "../internal-urls/local-protocol";
|
||||
import { InternalUrlRouter } from "../internal-urls/router";
|
||||
@@ -1197,6 +1197,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
}
|
||||
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
||||
}
|
||||
if (hashContext?.tag) {
|
||||
const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
|
||||
recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
|
||||
}
|
||||
return { model: modelOut, display: displayOut };
|
||||
};
|
||||
const useGroupedOutput = isDirectory || isMultiScope;
|
||||
|
||||
@@ -76,8 +76,13 @@ interface TransformersEnv {
|
||||
cacheDir?: string;
|
||||
allowLocalModels?: boolean;
|
||||
logLevel?: unknown;
|
||||
backends?: {
|
||||
onnx?: {
|
||||
logLevel?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
LogLevel: {
|
||||
LogLevel?: {
|
||||
ERROR: unknown;
|
||||
};
|
||||
}
|
||||
@@ -146,7 +151,8 @@ function toKokoroDevice(device: TinyModelDevice): KokoroDevice {
|
||||
function configureTransformers(transformers: TransformersEnv): void {
|
||||
transformers.env.cacheDir = getTinyModelsCacheDir();
|
||||
transformers.env.allowLocalModels = false;
|
||||
transformers.env.logLevel = transformers.LogLevel.ERROR;
|
||||
transformers.env.logLevel = transformers.LogLevel?.ERROR ?? "error";
|
||||
if (transformers.env.backends?.onnx) transformers.env.backends.onnx.logLevel = "error";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,9 +188,11 @@ async function loadKokoroRuntime(
|
||||
installRuntimeModuleResolver({ runtimeNodeModules: nodeModules, stubs: { sharp: sharpStub } });
|
||||
const kokoroEntry = resolveRuntimeModule(nodeModules, KOKORO_PACKAGE);
|
||||
if (!kokoroEntry) throw new Error(`Unable to resolve ${KOKORO_PACKAGE} in runtime at ${nodeModules}`);
|
||||
const entryRequire = createRequire(kokoroEntry);
|
||||
configureTransformers(entryRequire(TRANSFORMERS_PACKAGE) as TransformersEnv);
|
||||
return entryRequire(kokoroEntry) as KokoroRuntime;
|
||||
const transformersEntry = resolveRuntimeModule(nodeModules, TRANSFORMERS_PACKAGE);
|
||||
if (!transformersEntry) throw new Error(`Unable to resolve ${TRANSFORMERS_PACKAGE} in runtime at ${nodeModules}`);
|
||||
const runtimeRequire = createRequire(kokoroEntry);
|
||||
configureTransformers(runtimeRequire(transformersEntry) as TransformersEnv);
|
||||
return runtimeRequire(kokoroEntry) as KokoroRuntime;
|
||||
})().catch(error => {
|
||||
kokoroRuntime = null;
|
||||
throw error;
|
||||
|
||||
@@ -7,9 +7,22 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { $env, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/** Returns the user's preferred editor command, or undefined if not configured. */
|
||||
/**
|
||||
* Returns the user's preferred editor command, or a platform default.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `$VISUAL`
|
||||
* 2. `$EDITOR`
|
||||
* 3. `notepad` on Windows (always present in `%SystemRoot%\System32`)
|
||||
*
|
||||
* POSIX returns `undefined` when neither variable is set so the caller can
|
||||
* surface a warning that nudges the user to configure one.
|
||||
*/
|
||||
export function getEditorCommand(): string | undefined {
|
||||
return $env.VISUAL || $env.EDITOR || undefined;
|
||||
const configured = $env.VISUAL?.trim() || $env.EDITOR?.trim();
|
||||
if (configured) return configured;
|
||||
if (process.platform === "win32") return "notepad";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface OpenInEditorOptions {
|
||||
|
||||
@@ -73,7 +73,7 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
|
||||
const availableModels = registry.getAvailable();
|
||||
if (availableModels.length === 0) return undefined;
|
||||
|
||||
const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels, registry)?.model;
|
||||
const titleModel = resolveRoleSelection(["title", "commit", "smol"], settings, availableModels, registry)?.model;
|
||||
if (titleModel) return titleModel;
|
||||
|
||||
if (currentModel) return currentModel;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { ffmpegAssetName } from "./tools-manager";
|
||||
|
||||
describe("ffmpegAssetName", () => {
|
||||
it("maps supported platform/arch pairs to direct-binary asset names", () => {
|
||||
expect(ffmpegAssetName("b6.1.1", "darwin", "arm64")).toBe("ffmpeg-darwin-arm64");
|
||||
expect(ffmpegAssetName("b6.1.1", "darwin", "x64")).toBe("ffmpeg-darwin-x64");
|
||||
expect(ffmpegAssetName("b6.1.1", "linux", "arm64")).toBe("ffmpeg-linux-arm64");
|
||||
expect(ffmpegAssetName("b6.1.1", "linux", "x64")).toBe("ffmpeg-linux-x64");
|
||||
expect(ffmpegAssetName("b6.1.1", "win32", "x64")).toBe("ffmpeg-win32-x64");
|
||||
});
|
||||
|
||||
it("returns null for win32 on arm64 (no static asset published)", () => {
|
||||
expect(ffmpegAssetName("b6.1.1", "win32", "arm64")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unsupported arch", () => {
|
||||
expect(ffmpegAssetName("b6.1.1", "darwin", "ia32")).toBeNull();
|
||||
expect(ffmpegAssetName("b6.1.1", "linux", "ppc64")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unsupported platform", () => {
|
||||
expect(ffmpegAssetName("b6.1.1", "freebsd", "x64")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,9 @@ export async function buildDirectoryTree(cwd: string, options: BuildDirectoryTre
|
||||
rootLimit,
|
||||
lineCap: options.lineCap === undefined ? null : options.lineCap,
|
||||
nativeTruncated,
|
||||
// Tool output (read tool directory listing), not a cached prefix —
|
||||
// the human-friendly relative "ago" is appropriate here.
|
||||
ageMode: "relative",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,6 +102,10 @@ export async function buildWorkspaceTree(cwd: string, options: BuildWorkspaceTre
|
||||
rootLimit: WORKSPACE_DEFAULTS.perDirLimit,
|
||||
lineCap: WORKSPACE_DEFAULTS.lineCap,
|
||||
nativeTruncated: result.truncated,
|
||||
// This tree is embedded in the cached system prompt. Render absolute
|
||||
// mtimes so the block is byte-identical across sessions and does not
|
||||
// bust the prompt cache (a relative "Nm ago" drifts every build).
|
||||
ageMode: "absolute",
|
||||
});
|
||||
return { ...tree, agentsMdFiles: result.agentsMdFiles };
|
||||
} catch {
|
||||
@@ -132,6 +139,13 @@ interface AssembleOptions {
|
||||
rootLimit: number | null;
|
||||
lineCap: number | null;
|
||||
nativeTruncated: boolean;
|
||||
/**
|
||||
* How per-entry modification times are rendered.
|
||||
* - "relative": render-time "Nm ago" (fine for tool output).
|
||||
* - "absolute": deterministic UTC timestamp (prompt-cache-stable; used for
|
||||
* the system-prompt workspace tree). See {@link makeAgeFormatter}.
|
||||
*/
|
||||
ageMode: "relative" | "absolute";
|
||||
}
|
||||
|
||||
function assembleTree(rootPath: string, entries: readonly GlobMatch[], opts: AssembleOptions): DirectoryTree {
|
||||
@@ -187,7 +201,7 @@ function assembleTree(rootPath: string, entries: readonly GlobMatch[], opts: Ass
|
||||
}
|
||||
|
||||
const rawLines: RenderedLine[] = [];
|
||||
renderNode(root, Date.now(), rawLines);
|
||||
renderNode(root, makeAgeFormatter(opts.ageMode), rawLines);
|
||||
const { lines, elidedCount } = applyLineCap(rawLines, opts.lineCap);
|
||||
|
||||
return {
|
||||
@@ -202,7 +216,33 @@ function byRecency(a: Node, b: Node): number {
|
||||
return b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function renderNode(node: Node, nowMs: number, out: RenderedLine[]): void {
|
||||
/**
|
||||
* Build the per-node age formatter for a single render pass.
|
||||
*
|
||||
* - "relative": a render-time "Nm ago" string (computed once from `Date.now()`).
|
||||
* Used for tool output that is not part of any cached prefix.
|
||||
* - "absolute": a deterministic UTC `YYYY-MM-DD HH:MM` derived purely from the
|
||||
* file's mtime. Used for the system-prompt workspace tree so the rendered
|
||||
* block stays byte-identical across sessions. A relative age is recomputed on
|
||||
* every build, so two sessions seconds apart differ ("9m ago" → "10m ago");
|
||||
* because KV cache is contextual, that early change invalidates the cache for
|
||||
* everything after the tree — including the multi-thousand-token tool block —
|
||||
* forcing a full prompt re-prefill on every new session. An absolute mtime
|
||||
* only changes when the file itself changes, which is the correct trigger.
|
||||
*/
|
||||
function makeAgeFormatter(mode: "relative" | "absolute"): (mtimeMs: number) => string {
|
||||
if (mode === "absolute") return formatMtimeStable;
|
||||
const nowMs = Date.now();
|
||||
return (mtimeMs: number) => formatAge(Math.max(0, Math.floor((nowMs - mtimeMs) / 1000)));
|
||||
}
|
||||
|
||||
/** Deterministic, render-time-independent timestamp: UTC `YYYY-MM-DD HH:MM`. */
|
||||
function formatMtimeStable(mtimeMs: number): string {
|
||||
if (!mtimeMs) return "";
|
||||
return new Date(mtimeMs).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function renderNode(node: Node, formatNodeAge: (mtimeMs: number) => string, out: RenderedLine[]): void {
|
||||
if (node.depth === 0) {
|
||||
out.push({ label: node.name, depth: 0, isRoot: true });
|
||||
} else {
|
||||
@@ -213,26 +253,26 @@ function renderNode(node: Node, nowMs: number, out: RenderedLine[]): void {
|
||||
depth: node.depth,
|
||||
isRoot: false,
|
||||
size: node.isDir ? undefined : formatBytes(node.size),
|
||||
age: formatAge(Math.max(0, Math.floor((nowMs - node.mtimeMs) / 1000))),
|
||||
age: formatNodeAge(node.mtimeMs),
|
||||
});
|
||||
}
|
||||
|
||||
if (node.droppedCount === 0) {
|
||||
for (const child of node.children) renderNode(child, nowMs, out);
|
||||
for (const child of node.children) renderNode(child, formatNodeAge, out);
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: recent children, then "… N more" marker, then the oldest child.
|
||||
const recent = node.children.slice(0, -1);
|
||||
const oldest = node.children.at(-1);
|
||||
for (const child of recent) renderNode(child, nowMs, out);
|
||||
for (const child of recent) renderNode(child, formatNodeAge, out);
|
||||
const childDepth = node.depth + 1;
|
||||
out.push({
|
||||
label: `${" ".repeat(childDepth)}- … ${node.droppedCount} more`,
|
||||
depth: childDepth,
|
||||
isRoot: false,
|
||||
});
|
||||
if (oldest) renderNode(oldest, nowMs, out);
|
||||
if (oldest) renderNode(oldest, formatNodeAge, out);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -153,6 +153,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
expect(session.queuedMessageCount).toBe(1);
|
||||
|
||||
// Cleanup
|
||||
session.agent.clearAllQueues();
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
@@ -169,6 +170,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
expect(session.queuedMessageCount).toBe(1);
|
||||
|
||||
// Cleanup
|
||||
session.agent.clearAllQueues();
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -1237,4 +1237,63 @@ describe("AgentSession retry fallback", () => {
|
||||
await modelRegistry.refresh("offline");
|
||||
expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o")).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-retries Gemini MALFORMED_FUNCTION_CALL transient errors", async () => {
|
||||
const model = getBundledModel("google", "gemini-1.5-flash");
|
||||
if (!model) {
|
||||
throw new Error("Expected bundled Google test model to exist");
|
||||
}
|
||||
|
||||
const malformedError = "Generation failed with finish reason: MALFORMED_FUNCTION_CALL";
|
||||
const requestedModels: string[] = [];
|
||||
|
||||
const mock = createMockModel({
|
||||
responses: [{ throw: malformedError }, { content: ["Recovered after Gemini malformed function call"] }],
|
||||
});
|
||||
const agent = new Agent({
|
||||
getApiKey: provider => `${provider}-test-key`,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: ["Test"],
|
||||
tools: [],
|
||||
messages: [],
|
||||
},
|
||||
streamFn: (requestedModel, context, options) => {
|
||||
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
||||
return mock.stream(requestedModel, context, options);
|
||||
},
|
||||
});
|
||||
|
||||
const settings = Settings.isolated({
|
||||
"compaction.enabled": false,
|
||||
"retry.baseDelayMs": 5,
|
||||
"retry.maxRetries": 1,
|
||||
});
|
||||
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settings,
|
||||
modelRegistry,
|
||||
});
|
||||
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
||||
|
||||
await session.prompt("recover from Gemini malformed error");
|
||||
await session.waitForIdle();
|
||||
|
||||
expect(mock.calls).toHaveLength(2);
|
||||
expect(retryStartEvents).toHaveLength(1);
|
||||
expect(retryEndEvents).toHaveLength(1);
|
||||
expect(session.agent.state.messages).toHaveLength(2);
|
||||
const assistantMsg = session.agent.state.messages[1];
|
||||
if (assistantMsg.role !== "assistant") {
|
||||
throw new Error(`Expected assistant message, got ${assistantMsg.role}`);
|
||||
}
|
||||
const contentBlock = assistantMsg.content[0];
|
||||
if (contentBlock.type !== "text") {
|
||||
throw new Error(`Expected text content block, got ${contentBlock.type}`);
|
||||
}
|
||||
expect(contentBlock.text).toBe("Recovered after Gemini malformed function call");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import type { AssistantMessage, TextContent, ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { TempDir, withTimeout } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/**
|
||||
* Regression coverage for issue #2590: `#checkTodoCompletion` used to schedule
|
||||
* `agent.continue()` after appending its `<system-reminder>`, so any text-only
|
||||
* acknowledgement from the agent ("paused at your instruction") triggered another
|
||||
* `agent_end`, which incremented the counter and fired the next reminder — no
|
||||
* user input required. Within a single user pause that loop runs 1/3 → 2/3 → 3/3.
|
||||
*
|
||||
* The contract these tests defend: a reminder MUST NOT escalate inside a
|
||||
* self-continuation chain unless the agent has produced a tool-level result
|
||||
* (e.g. called `todo` or `edit`) between the prior reminder and the next stop.
|
||||
*/
|
||||
describe("AgentSession todo reminder self-continuation suppression", () => {
|
||||
let tempDir: TempDir;
|
||||
let session: AgentSession;
|
||||
let sessionManager: SessionManager;
|
||||
let authStorage: AuthStorage;
|
||||
let modelRegistry: ModelRegistry;
|
||||
let reminderAttempts: number[];
|
||||
let firstReminderPromise: Promise<void>;
|
||||
let resolveFirstReminder: () => void;
|
||||
|
||||
function textOnlyAssistantMessage(): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "paused at your instruction" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 20,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 120,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function emitTextOnlyStop(): void {
|
||||
const msg = textOnlyAssistantMessage();
|
||||
session.agent.emitExternalEvent({ type: "message_end", message: msg });
|
||||
session.agent.emitExternalEvent({ type: "agent_end", messages: [msg] });
|
||||
}
|
||||
|
||||
function emitToolResult(toolName: string, details: Record<string, unknown> = {}): void {
|
||||
const toolCallId = `call_${toolName}_${Date.now()}_${Math.random()}`;
|
||||
const toolCall: ToolCall = { type: "toolCall", id: toolCallId, name: toolName, arguments: {} };
|
||||
const assistantMsg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [toolCall],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "toolUse",
|
||||
usage: {
|
||||
input: 50,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 60,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
|
||||
const content: TextContent[] = [{ type: "text", text: "ok" }];
|
||||
session.agent.emitExternalEvent({
|
||||
type: "message_end",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName,
|
||||
content,
|
||||
isError: false,
|
||||
details,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = TempDir.createSync("@pi-todo-reminder-loop-");
|
||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
modelRegistry = new ModelRegistry(authStorage);
|
||||
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
|
||||
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) throw new Error("Expected built-in anthropic model to exist");
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: ["Test"],
|
||||
tools: [],
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settings: Settings.isolated({
|
||||
"compaction.enabled": false,
|
||||
"todo.enabled": true,
|
||||
"todo.reminders": true,
|
||||
"todo.reminders.max": 3,
|
||||
}),
|
||||
modelRegistry,
|
||||
});
|
||||
|
||||
reminderAttempts = [];
|
||||
({ promise: firstReminderPromise, resolve: resolveFirstReminder } = Promise.withResolvers<void>());
|
||||
session.subscribe((event: AgentSessionEvent) => {
|
||||
if (event.type === "todo_reminder") {
|
||||
reminderAttempts.push(event.attempt);
|
||||
if (reminderAttempts.length === 1) resolveFirstReminder();
|
||||
}
|
||||
});
|
||||
|
||||
session.setTodoPhases([
|
||||
{
|
||||
name: "Pending review",
|
||||
tasks: [
|
||||
{ content: "Slice 81", status: "pending" },
|
||||
{ content: "Slice 82", status: "pending" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await session.dispose();
|
||||
authStorage.close();
|
||||
try {
|
||||
await tempDir.remove();
|
||||
} catch {}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("baseline: a single text-only stop fires reminder 1/3", async () => {
|
||||
vi.spyOn(session.agent, "continue").mockResolvedValue();
|
||||
emitTextOnlyStop();
|
||||
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
|
||||
expect(reminderAttempts).toEqual([1]);
|
||||
});
|
||||
|
||||
it("fires exactly one reminder per user pause when the agent only acknowledges", async () => {
|
||||
// Each call to continue() mirrors what the bug-reported model did: emit another
|
||||
// text-only stop ("paused at your instruction"), no tool calls in between.
|
||||
vi.spyOn(session.agent, "continue").mockImplementation(async () => {
|
||||
emitTextOnlyStop();
|
||||
});
|
||||
|
||||
emitTextOnlyStop();
|
||||
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
|
||||
await session.waitForIdle();
|
||||
|
||||
// With the bug: reminderAttempts === [1, 2, 3] within a single user pause.
|
||||
// With the fix: the second `agent_end` is suppressed because no tool action ran
|
||||
// between the first reminder and the agent's text-only ack.
|
||||
expect(reminderAttempts).toEqual([1]);
|
||||
});
|
||||
|
||||
it("re-escalates after the agent makes tool-level progress between stops", async () => {
|
||||
let continueCount = 0;
|
||||
vi.spyOn(session.agent, "continue").mockImplementation(async () => {
|
||||
continueCount += 1;
|
||||
if (continueCount === 1) {
|
||||
// In response to reminder 1/3 the agent actually did work (called `todo`),
|
||||
// then stopped again with todos still incomplete.
|
||||
emitToolResult("todo", { phases: session.getTodoPhases() });
|
||||
emitTextOnlyStop();
|
||||
return;
|
||||
}
|
||||
// Subsequent continuations are bare acks — they must not escalate further.
|
||||
emitTextOnlyStop();
|
||||
});
|
||||
|
||||
emitTextOnlyStop();
|
||||
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
|
||||
await session.waitForIdle();
|
||||
|
||||
// 1/3 fires, agent does work, 2/3 fires, agent acks → suppressed, no 3/3.
|
||||
expect(reminderAttempts).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,16 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config
|
||||
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
|
||||
import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state";
|
||||
|
||||
describe("autocompleteMaxVisible setting", () => {
|
||||
let testDir: string;
|
||||
let settingsState: SettingsTestState | undefined;
|
||||
let testDir = "";
|
||||
let agentDir: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
resetSettingsForTest();
|
||||
settingsState = beginSettingsTest();
|
||||
testDir = path.join(os.tmpdir(), "test-autocomplete-settings", Snowflake.next());
|
||||
agentDir = path.join(testDir, "agent");
|
||||
projectDir = path.join(testDir, "project");
|
||||
@@ -22,10 +24,12 @@ describe("autocompleteMaxVisible setting", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetSettingsForTest();
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true });
|
||||
restoreSettingsTestState(settingsState);
|
||||
settingsState = undefined;
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
testDir = "";
|
||||
});
|
||||
|
||||
it("should persist and read back a configured value", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from "node:path";
|
||||
import { getManagedSkillsDir } from "@oh-my-pi/pi-coding-agent/autolearn/managed-skills";
|
||||
import "@oh-my-pi/pi-coding-agent/discovery";
|
||||
import { loadSkills } from "@oh-my-pi/pi-coding-agent/extensibility/skills";
|
||||
import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
async function writeSkill(dir: string, name: string, description: string): Promise<void> {
|
||||
const file = path.join(dir, name, "SKILL.md");
|
||||
@@ -18,13 +19,16 @@ describe("managed-skills discovery", () => {
|
||||
let managedDir: string;
|
||||
let authoredDir: string;
|
||||
|
||||
let originalAgentDir: string;
|
||||
beforeEach(async () => {
|
||||
originalAgentDir = getAgentDir();
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-managed-disco-home-"));
|
||||
// cwd MUST live under the fake home so loadSkills' ancestor walk is bounded
|
||||
// and cannot pick up ambient /tmp/.omp or /.omp fixtures (full-suite-safe).
|
||||
tempCwd = path.join(tempHome, "work");
|
||||
await fs.mkdir(tempCwd, { recursive: true });
|
||||
spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
setAgentDir(path.join(tempHome, ".omp", "agent"));
|
||||
managedDir = getManagedSkillsDir();
|
||||
// Authored user skills live in the sibling `skills/` dir under .../agent.
|
||||
authoredDir = path.join(path.dirname(managedDir), "skills");
|
||||
@@ -32,6 +36,7 @@ describe("managed-skills discovery", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
spyOn(os, "homedir").mockRestore();
|
||||
setAgentDir(originalAgentDir);
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -11,17 +11,22 @@ import {
|
||||
writeManagedSkill,
|
||||
} from "@oh-my-pi/pi-coding-agent/autolearn/managed-skills";
|
||||
import { parseFrontmatter } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
describe("managed-skills primitives", () => {
|
||||
let tempHome: string;
|
||||
|
||||
let originalAgentDir: string;
|
||||
beforeEach(async () => {
|
||||
originalAgentDir = getAgentDir();
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-managed-skills-"));
|
||||
spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
setAgentDir(path.join(tempHome, ".omp", "agent"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
spyOn(os, "homedir").mockRestore();
|
||||
setAgentDir(originalAgentDir);
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user