diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7324baa1d3..92c8b47456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,16 +197,45 @@ jobs: - uses: swatinem/rust-cache@v2 with: - shared-key: "ci-check-v3-${{ runner.os }}-no-cargo-bin-v1" + shared-key: "ci-check-v5-${{ runner.os }}-no-cargo-bin-v1" cache-bin: false # PR caches are scoped to merge refs; trusted main pushes own shared # refreshes and retain completed dependency builds after late test failures. save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} cache-on-failure: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + # rust-cache prunes the workspace target directory before saving it, so + # native libraries stored under target need an independent cache lifecycle. + - name: Restore Sherpa native libraries + id: sherpa-native-cache + uses: actions/cache/restore@v5 + with: + path: target/sherpa-onnx-prebuilt + key: sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static + + - name: Repair missing Sherpa native state + shell: bash + run: | + if ! find target/sherpa-onnx-prebuilt -type f \ + \( -name 'sherpa-onnx-c-api.lib' -o -name 'libsherpa-onnx-c-api.a' \) \ + -print -quit 2>/dev/null | grep -q .; then + rm -rf target/sherpa-onnx-prebuilt + cargo clean -p sherpa-onnx-sys + fi + - name: Check compilation run: cargo check --locked --workspace + - name: Save Sherpa native libraries + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + steps.sherpa-native-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: target/sherpa-onnx-prebuilt + key: sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static + # The installer is intentionally excluded from the root Cargo workspace, # so the workspace check above cannot catch drift in its shared Rust APIs. - name: Check installer compilation @@ -249,6 +278,61 @@ jobs: - name: Run search tool tests run: "cargo test --locked -p tool-runtime --lib search::" + # ── DeepSeek Harness bridge: profile packaging on Windows ────────── + # `prepare:dsh-profile` runs from `frontend:build-all` / official desktop + # packaging, not from desktop:dev or cargo check. Until this job existed, + # its first Windows execution ever was a release build, and it failed there + # (`spawnSync npm ENOENT`: npm is a `.cmd` shim Node will not spawn without + # a shell). This job is the cheapest thing that exercises the whole + # packaging path — npm install, tsc, `npm pack`, tar, tree copy — on the + # platform whose path and process rules differ. + dsh-profile-windows: + name: DSH Profile Packaging (windows-latest) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: package.json + package-manager-cache: false + + - name: Build the bridge profile + run: node scripts/prepare-dsh-profile.mjs + + - name: Verify the profile is complete and stamped + shell: bash + run: | + set -euo pipefail + root=packages/dsh-acp/dist-profile + for path in \ + "$root/.bitfun-bridge.json" \ + "$root/cordis.patch.yml" \ + "$root/package.json" \ + "$root/lib/app.js" \ + "$root/node_modules/@agentclientprotocol/sdk/package.json" \ + "$root/node_modules/@deepseek-ai/dsh-agent-spine-demo/package.json" + do + [ -f "$path" ] || { echo "::error::missing $path"; exit 1; } + done + # A vendored tree copied with a broken separator filter drags + # node_modules along; the profile resolves those from the user's own + # dsh installation and must ship none of its own beyond the two above. + nested=$(find "$root/lib" "$root/presets" -name node_modules -o -name '*.map' || true) + if [ -n "$nested" ]; then + echo "::error::profile carries files it must not ship:" + echo "$nested" + exit 1 + fi + node -e ' + const stamp = require("./packages/dsh-acp/dist-profile/.bitfun-bridge.json"); + if (stamp.profile !== "bitfun-acp") throw new Error("wrong profile name: " + stamp.profile); + if (!/^[0-9a-f]{64}$/.test(stamp.content)) throw new Error("no content digest"); + process.stdout.write(`profile ${stamp.profile} @ ${stamp.bridge}, min dsh ${stamp.minDshVersion}\n`); + ' + # ── Frontend: build ──────────────────────────────────────────────── frontend-build: name: Frontend Build @@ -306,6 +390,9 @@ jobs: - name: Validate theme visual governance contract run: pnpm run theme:visual-contract + - name: Validate WebKit compatibility gate + run: pnpm run verify:webkit-compatibility:test + - name: Lint web UI run: pnpm run lint:web diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 27c5af5b03..a88dd8401c 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -7,9 +7,21 @@ on: workflow_dispatch: inputs: tag_name: - description: "Tag name to build (e.g. v0.2.0). Leave empty to build from HEAD." + description: "Release tag (stable: v0.2.18, beta: v0.2.18-beta.1)." required: false type: string + checkout_ref: + description: "Commit, branch, or tag to build. Defaults to tag_name, then HEAD." + required: false + type: string + release_channel: + description: "Immutable update channel compiled into the Desktop artifact." + required: false + default: stable + type: choice + options: + - stable + - beta upload_to_release: description: "Upload built artifacts to the release specified by tag_name." required: false @@ -26,8 +38,8 @@ permissions: packages: write concurrency: - group: desktop-package-${{ github.event.release.tag_name || inputs.tag_name || github.sha }} - cancel-in-progress: true + group: desktop-package-${{ (github.event.release.prerelease || inputs.release_channel == 'beta') && 'beta' || github.event.release.tag_name || inputs.tag_name || github.sha }} + cancel-in-progress: false jobs: # ── Resolve version info ─────────────────────────────────────────── @@ -39,9 +51,12 @@ jobs: release_tag: ${{ steps.meta.outputs.release_tag }} upload_to_release: ${{ steps.meta.outputs.upload_to_release }} checkout_ref: ${{ steps.meta.outputs.checkout_ref }} + release_channel: ${{ steps.meta.outputs.release_channel }} relay_image_only: ${{ steps.meta.outputs.relay_image_only }} steps: - uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Resolve version metadata id: meta @@ -51,6 +66,8 @@ jobs: GITHUB_SHA: ${{ github.sha }} RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} INPUT_TAG_NAME: ${{ inputs.tag_name }} + INPUT_CHECKOUT_REF: ${{ inputs.checkout_ref }} + INPUT_RELEASE_CHANNEL: ${{ inputs.release_channel }} INPUT_UPLOAD_TO_RELEASE: ${{ inputs.upload_to_release }} INPUT_RELAY_IMAGE_ONLY: ${{ inputs.relay_image_only }} run: | @@ -61,13 +78,21 @@ jobs: VERSION="${TAG#v}" UPLOAD="true" CHECKOUT_REF="${TAG}" + if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + CHANNEL="beta" + else + CHANNEL="stable" + fi elif [[ -n "${INPUT_TAG_NAME}" ]]; then TAG="${INPUT_TAG_NAME}" VERSION="${TAG#v}" + CHANNEL="${INPUT_RELEASE_CHANNEL:-stable}" # A one-off image backfill must use the workflow branch: an older # tag does not contain Dockerfile.release or this publishing job. if [[ "${INPUT_RELAY_IMAGE_ONLY}" == "true" ]]; then CHECKOUT_REF="${GITHUB_SHA}" + elif [[ -n "${INPUT_CHECKOUT_REF}" ]]; then + CHECKOUT_REF="${INPUT_CHECKOUT_REF}" else CHECKOUT_REF="${TAG}" fi @@ -81,12 +106,32 @@ jobs: TAG="v${VERSION}" UPLOAD="false" CHECKOUT_REF="${GITHUB_SHA}" + CHANNEL="${INPUT_RELEASE_CHANNEL:-stable}" + fi + + node --input-type=module -e \ + "import { validateReleaseVersion } from './scripts/release-channel.mjs'; validateReleaseVersion(process.argv[1], process.argv[2]);" \ + "${CHANNEL}" "${VERSION}" + + git fetch origin main --tags --force + CHECKOUT_SHA="$(git rev-parse "${CHECKOUT_REF}^{commit}")" + if [[ "${GITHUB_REPOSITORY}" == "GCWing/BitFun" ]] && \ + ! git merge-base --is-ancestor "${CHECKOUT_SHA}" origin/main; then + echo "Ref ${CHECKOUT_REF} (${CHECKOUT_SHA}) is not part of the protected main history." >&2 + exit 1 fi + TAG_SHA="$(git rev-parse --verify --quiet "${TAG}^{commit}" || true)" + if [[ -n "${TAG_SHA}" && "${TAG_SHA}" != "${CHECKOUT_SHA}" ]]; then + echo "Existing tag ${TAG} points to ${TAG_SHA}, not requested commit ${CHECKOUT_SHA}." >&2 + exit 1 + fi + CHECKOUT_REF="${CHECKOUT_SHA}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" echo "upload_to_release=$UPLOAD" >> "$GITHUB_OUTPUT" echo "checkout_ref=$CHECKOUT_REF" >> "$GITHUB_OUTPUT" + echo "release_channel=$CHANNEL" >> "$GITHUB_OUTPUT" echo "relay_image_only=${INPUT_RELAY_IMAGE_ONLY:-false}" >> "$GITHUB_OUTPUT" # ── Build per platform ───────────────────────────────────────────── @@ -98,12 +143,14 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=6144 BITFUN_ENABLE_UPDATER_ARTIFACTS: ${{ needs.prepare.outputs.upload_to_release }} - TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json + BITFUN_RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} + TAURI_UPDATER_ENDPOINT: ${{ github.repository != 'GCWing/BitFun' && needs.prepare.outputs.release_channel == 'beta' && format('https://github.com/{0}/releases/download/channel-beta/latest.json', github.repository) || '' }} + TAURI_UPDATER_FALLBACK_ENDPOINT: ${{ github.repository != 'GCWing/BitFun' && needs.prepare.outputs.release_channel == 'beta' && format('https://github.com/{0}/releases/download/channel-beta/latest.json', github.repository) || '' }} TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} # Same trust root, compiled into the Desktop binary so one-click relay # deploy can verify the signed checksum locally and hand the remote host # a hash it does not have to trust the mirror for. - BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + BITFUN_RELEASE_PUBKEY: ${{ secrets.BITFUN_RELEASE_PUBKEY || secrets.TAURI_UPDATER_PUBKEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -233,12 +280,35 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Configure Apple Developer ID signing and notarization + if: runner.os == 'macOS' + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + BITFUN_REQUIRE_APPLE_SIGNING: ${{ needs.prepare.outputs.upload_to_release }} + run: bash scripts/ci/setup-macos-signing.sh + + - name: Project beta build version + if: needs.prepare.outputs.release_channel == 'beta' + run: node scripts/set-build-version.mjs --version "${{ needs.prepare.outputs.version }}" + - name: Verify release version metadata run: node scripts/verify-release-version-sync.mjs --version "${{ needs.prepare.outputs.version }}" - name: Build desktop app run: ${{ matrix.platform.build_command }} + - name: Verify Apple signature and notarization + if: runner.os == 'macOS' + shell: bash + run: bash scripts/ci/verify-macos-signing.sh "${{ matrix.platform.target }}" + - name: Verify AppImage fcitx5 GTK module if: runner.os == 'Linux' shell: bash @@ -258,7 +328,9 @@ jobs: linux-binaries: name: Linux CLI and Relay Server needs: prepare - if: needs.prepare.outputs.relay_image_only != 'true' + if: >- + needs.prepare.outputs.relay_image_only != 'true' && + needs.prepare.outputs.release_channel == 'stable' uses: ./.github/workflows/linux-binaries.yml secrets: release_signing_key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -276,7 +348,8 @@ jobs: needs: [prepare, linux-binaries] if: >- always() && - (needs.prepare.outputs.upload_to_release == 'true' || + ((needs.prepare.outputs.upload_to_release == 'true' && + needs.prepare.outputs.release_channel == 'stable') || needs.prepare.outputs.relay_image_only == 'true') && (needs.prepare.outputs.relay_image_only == 'true' || needs.linux-binaries.result == 'success') @@ -348,7 +421,7 @@ jobs: RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} RELEASE_VERSION: ${{ needs.prepare.outputs.version }} IMAGE_ONLY: ${{ needs.prepare.outputs.relay_image_only }} - RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} run: | set -euo pipefail asset_version="${RELEASE_VERSION%%+*}" @@ -363,7 +436,7 @@ jobs: if [[ "${RELEASE_TAG}" == "${latest_release}" ]]; then echo "${IMAGE}:latest" fi - elif [[ "${RELEASE_PRERELEASE:-false}" != "true" ]]; then + elif [[ "${RELEASE_CHANNEL}" == "stable" ]]; then # The normal release workflow can create the GitHub Release only # after packaging, so it cannot rely on /releases/latest yet. echo "${IMAGE}:latest" @@ -469,8 +542,9 @@ jobs: always() && needs.prepare.outputs.upload_to_release == 'true' && needs.package.result == 'success' && - needs.linux-binaries.result == 'success' && - needs.publish-relay-image.result == 'success' + (needs.prepare.outputs.release_channel == 'beta' || + (needs.linux-binaries.result == 'success' && + needs.publish-relay-image.result == 'success')) runs-on: ubuntu-latest env: REQUIRED_UPDATER_PLATFORMS: windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64 @@ -487,6 +561,7 @@ jobs: merge-multiple: true - name: Download Linux binary artifacts + if: needs.prepare.outputs.release_channel == 'stable' uses: actions/download-artifact@v7 with: pattern: bitfun-linux-${{ needs.prepare.outputs.release_tag }}-* @@ -494,19 +569,24 @@ jobs: merge-multiple: true - name: Download Relay image descriptor + if: needs.prepare.outputs.release_channel == 'stable' uses: actions/download-artifact@v7 with: name: bitfun-relay-image-${{ needs.prepare.outputs.release_tag }} path: relay-image-assets - name: List release assets + env: + RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} run: | echo "Release assets:" find release-assets -type f | sort - echo "Linux CLI and Relay Server assets:" - find linux-release-assets -type f | sort - echo "Relay image descriptor:" - find relay-image-assets -type f | sort + if [[ "${RELEASE_CHANNEL}" == "stable" ]]; then + echo "Linux CLI and Relay Server assets:" + find linux-release-assets -type f | sort + echo "Relay image descriptor:" + find relay-image-assets -type f | sort + fi - name: Prepare versioned Windows installer run: | @@ -538,7 +618,7 @@ jobs: --manual-assets-dir release-manual-assets \ --version "${{ needs.prepare.outputs.version }}" \ --tag "${{ needs.prepare.outputs.release_tag }}" \ - --repo "GCWing/BitFun" \ + --repo "${{ github.repository }}" \ --out release-updater-assets/latest.json \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" @@ -551,6 +631,7 @@ jobs: --required-manual-platforms "windows-x86_64" - name: Generate Linux binaries manifest + if: needs.prepare.outputs.release_channel == 'stable' run: | node scripts/generate-linux-binaries-manifest.mjs \ --assets-dir linux-release-assets \ @@ -586,9 +667,11 @@ jobs: # Publish the public key alongside the signatures: a signature nobody # can fetch a key for is not verifiable. - printf '%s' "${BITFUN_SIGNING_PUBKEY}" | base64 -d >release-assets/minisign.pub + node scripts/write-minisign-public-key.mjs \ + --out release-assets/minisign.pub - - name: Stage uniquely named release assets + - name: Stage stable release assets + if: needs.prepare.outputs.release_channel == 'stable' shell: bash run: | set -euo pipefail @@ -617,10 +700,34 @@ jobs: relay-image-assets/relay-image.json \ relay-image-assets/relay-image.json.sig + - name: Stage beta release assets + if: needs.prepare.outputs.release_channel == 'beta' + shell: bash + run: | + set -euo pipefail + shopt -s globstar + node scripts/stage-github-release-assets.mjs \ + --out-dir release-upload-assets \ + release-updater-assets/* \ + release-manual-assets/*.exe \ + release-manual-assets/*.exe.sig \ + release-assets/**/*.AppImage \ + release-assets/**/*.AppImage.sig \ + release-assets/**/*.deb \ + release-assets/**/*.deb.sig \ + release-assets/**/*.dmg \ + release-assets/**/*.dmg.sig \ + release-assets/**/*.rpm \ + release-assets/**/*.rpm.sig \ + release-assets/minisign.pub + - name: Upload to release uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.prepare.outputs.release_tag }} + target_commitish: ${{ needs.prepare.outputs.checkout_ref }} + prerelease: ${{ needs.prepare.outputs.release_channel == 'beta' }} + name: ${{ needs.prepare.outputs.release_channel == 'beta' && format('BitFun {0} Beta', needs.prepare.outputs.version) || format('BitFun {0}', needs.prepare.outputs.version) }} generate_release_notes: true files: release-upload-assets/* fail_on_unmatched_files: true @@ -628,7 +735,7 @@ jobs: - name: Verify published updater manifest run: | curl -fsSL --retry 5 --retry-delay 3 \ - "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/latest.json" \ + "https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/latest.json" \ -o latest.published.json node scripts/verify-tauri-latest-json.mjs \ --manifest latest.published.json \ @@ -638,6 +745,7 @@ jobs: --check-urls true - name: Verify published Linux binaries manifest + if: needs.prepare.outputs.release_channel == 'stable' run: | curl -fsSL --retry 5 --retry-delay 3 \ "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \ @@ -649,6 +757,7 @@ jobs: done < <(jq -r '.platforms[].cli.url' linux-binaries.published.json) - name: Verify published Relay image descriptor + if: needs.prepare.outputs.release_channel == 'stable' run: | curl -fsSL --retry 5 --retry-delay 3 \ "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json" \ @@ -660,16 +769,88 @@ jobs: "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json.sig" \ -o /dev/null + - name: Resolve beta channel promotion + id: beta-channel + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} + run: | + set -euo pipefail + channel_status="$(curl -sS --retry 5 --retry-delay 3 \ + --output channel.release.json \ + --write-out '%{http_code}' \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/channel-beta")" + case "${channel_status}" in + 200) + echo "channel_exists=true" >>"$GITHUB_OUTPUT" + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/${GITHUB_REPOSITORY}/releases/download/channel-beta/latest.json" \ + -o current.beta.json + ;; + 404) + echo "channel_exists=false" >>"$GITHUB_OUTPUT" + if [[ "${RELEASE_CHANNEL}" == "stable" ]]; then + echo "promote=false" >>"$GITHUB_OUTPUT" + echo "No beta channel exists; stable release does not need to create one." + exit 0 + fi + ;; + *) + echo "Could not inspect channel-beta release (GitHub API returned ${channel_status})." >&2 + exit 1 + ;; + esac + args=(--candidate latest.published.json --github-output "$GITHUB_OUTPUT") + if [[ -s current.beta.json ]]; then + args+=(--current current.beta.json) + fi + node scripts/plan-channel-promotion.mjs "${args[@]}" + + - name: Publish beta channel manifest + if: steps.beta-channel.outputs.promote == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CHECKOUT_REF: ${{ needs.prepare.outputs.checkout_ref }} + CHANNEL_EXISTS: ${{ steps.beta-channel.outputs.channel_exists }} + run: | + set -euo pipefail + if [[ "${CHANNEL_EXISTS}" != "true" ]]; then + gh release create channel-beta \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${CHECKOUT_REF}" \ + --prerelease \ + --title "BitFun Beta Update Channel" \ + --notes "Mutable updater pointer. Installable beta releases use immutable version tags." + fi + mkdir -p beta-channel + cp latest.published.json beta-channel/latest.json + gh release upload channel-beta beta-channel/latest.json \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/${GITHUB_REPOSITORY}/releases/download/channel-beta/latest.json" \ + -o channel-beta.published.json + test "$(jq -r '.version' channel-beta.published.json)" = \ + "${{ steps.beta-channel.outputs.candidate_version }}" + # Nudge the openbitfun.com mirror to sync now instead of on its next # 10-minute cron tick. Until the mirror has these bytes, CN clients have # only the GitHub origin to fall back to. Best effort: the cron run is # still the source of truth, so a failed or unconfigured ping never fails - # the release. Receiver setup: scripts/openbitfun-release-sync.sh. + # the release. Receiver: scripts/openbitfun-release-sync.sh. + # Host restore: deploy/openbitfun-host/README.md. - name: Request openbitfun mirror sync + if: github.repository == 'GCWing/BitFun' continue-on-error: true env: SYNC_WEBHOOK_URL: ${{ secrets.OPENBITFUN_SYNC_WEBHOOK_URL }} RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} run: | set -euo pipefail if [[ -z "${SYNC_WEBHOOK_URL:-}" ]]; then @@ -678,6 +859,6 @@ jobs: fi curl -fsSL -X POST --retry 3 --retry-delay 5 --max-time 30 \ -H 'Content-Type: application/json' \ - -d "{\"tag\":\"${RELEASE_TAG}\"}" \ + -d "{\"tag\":\"${RELEASE_TAG}\",\"channel\":\"${RELEASE_CHANNEL}\"}" \ "${SYNC_WEBHOOK_URL}" >/dev/null echo "Mirror sync requested for ${RELEASE_TAG}." diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2af1483c06..e80dd0751b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -68,6 +68,11 @@ jobs: if: needs.check-changes.outputs.should_build == 'true' env: NODE_OPTIONS: --max-old-space-size=6144 + BITFUN_RELEASE_CHANNEL: nightly + # Nightly does not publish a Tauri latest.json feed yet. Preserve its + # existing stable updater endpoints until that publishing path exists. + TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json + TAURI_UPDATER_FALLBACK_ENDPOINT: https://openbitfun.com/release/latest.json # Nightly relay archives are signed too (linux-binaries.yml receives the # key), so nightly Desktop needs the same trust root to verify them. BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} @@ -176,28 +181,8 @@ jobs: set -euo pipefail echo "Patching version to $NIGHTLY_VERSION" - - # Patch package.json - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); - pkg.version = process.env.NIGHTLY_VERSION.split('+')[0]; - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - for (const file of ['BitFun-Installer/package.json', 'BitFun-Installer/package-lock.json']) { - const data = JSON.parse(fs.readFileSync(file, 'utf-8')); - data.version = pkg.version; - if (data.packages?.['']) data.packages[''].version = pkg.version; - fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n'); - } - " - - # Patch Cargo workspace version (semver: nightly suffix uses hyphen) - # Cargo.toml only accepts: MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-PRE - CARGO_VERSION="$(echo "$NIGHTLY_VERSION" | sed 's/+.*//')" - sed -i.bak "s/^version = \".*\" # x-release-please-version/version = \"${CARGO_VERSION}\" # x-release-please-version/" Cargo.toml - rm -f Cargo.toml.bak - sed -i.bak "0,/^version = \".*\"/s//version = \"${CARGO_VERSION}\"/" BitFun-Installer/src-tauri/Cargo.toml - rm -f BitFun-Installer/src-tauri/Cargo.toml.bak + ASSET_VERSION="${NIGHTLY_VERSION%%+*}" + node scripts/set-build-version.mjs --version "$ASSET_VERSION" echo "package.json version: $(jq -r '.version' package.json)" echo "Cargo.toml version: $(grep 'x-release-please-version' Cargo.toml)" @@ -466,7 +451,8 @@ jobs: # Publish the public key alongside the signatures: a signature nobody # can fetch a key for is not verifiable. - printf '%s' "${BITFUN_SIGNING_PUBKEY}" | base64 -d >release-assets/minisign.pub + node scripts/write-minisign-public-key.mjs \ + --out release-assets/minisign.pub - name: Create nightly release uses: softprops/action-gh-release@v3 diff --git a/.gitignore b/.gitignore index 95806da18c..6b2f0197e4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ dist-ssr # Build outputs - Rust/Tauri target/ +sdk/typescript/src/internal/wire/ +sdk/typescript/src/internal/wire-validators.ts **/target/ /.targets/ # The deployable Rust services use the workspace lockfile for reproducible diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 093cce57d1..3bf286f4fc 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -12,7 +12,7 @@ BitFun 是一个由 Rust workspace 与 React 前端组成的项目。 2. 日常开发使用下方主要产品循环;surface 专属的替代命令由最近的应用指南维护。 3. 修改 Rust 文件后,优先使用 `pnpm run fmt:rs`,只格式化已改动或已暂存的 `.rs` 文件。只有在你明确需要更大范围格式化时才使用 `cargo fmt`。 4. 改完后从离改动最近的 `AGENTS.md` 选择 focused 验证命令;下方仓库级验证章节只维护跨模块检查原则。 -5. Rust workspace 依赖应在根清单中统一版本,而由消费 crate 按自身职责声明所需 feature;仅测试所需的 feature 应放入 `dev-dependencies`,受 crate feature 控制的服务能力应只在对应 feature 中启用。禁止使用 `tokio/full` 绕过依赖边界设计。 +5. Rust workspace 依赖应在根清单中统一版本,而由消费 crate 按自身职责声明所需 feature;仅测试所需的 feature 应放入 `dev-dependencies`,受 crate feature 控制的服务能力应只在对应 feature 中启用。第三方依赖的默认 feature 若不是所有 consumer 的稳定契约,应在 `[workspace.dependencies]` 统一关闭,成员只增加自身需要的切片。仓内 crate 的 `default` 已由边界契约保证为空时,不在每条依赖边重复写 `default-features = false`;ACP 这类有意保留兼容默认的 crate 仍由窄 consumer 显式关闭。被单独复制到 Docker 构建上下文的 manifest 无法继承 workspace 根,必须继续维护显式版本和默认策略。禁止使用 `tokio/full` 绕过依赖边界设计。 ## 分层模块索引 @@ -128,13 +128,72 @@ await api.invoke('your_command', { request: { ... } }); - 桌面端专属集成应放在 `src/apps/desktop`,再通过类型化能力接口回流;需要事件投递时,使用已有生产 transport adapter。 - 在共享 core 中避免使用 `tauri::AppHandle` 等宿主 API;优先使用 `bitfun_events::EventEmitter` 等共享抽象。 -### 远程兼容 - -- 新增功能时,从一开始就要考虑远程工作区和远程控制同步适配。只支持本地的行为很容易让远程场景功能缺失。 -- 如果某个功能无法合理支持远程工作区,必须做能力屏蔽,或展示明确的不支持提示,不能让它以通用错误的形式失败。 -- 每个桌面端 Tauri 命令都必须在 - `src/apps/desktop/src/api/remote_workspace_policy.rs` 中声明远程工作区策略; - 该文件的契约测试会拒绝没有显式策略的新命令,并禁止 legacy-unaudited 存量清单增长。 +### 远程场景 + +BitFun 不是只在本地运行的桌面应用:工作区、执行这一轮的 runtime、以及正在操作的人, +可能分别位于三台机器。下面四种场景是每次改动都要一并覆盖的一等目标,不是事后再补的适配。 + +| 场景 | 含义 | 设计入口 | +|---|---|---| +| 远程工作区 | 当前工作区位于 SSH 主机、跳板机链路或 Docker 容器;文件、终端、搜索和 Agent 子进程都必须在那一侧执行 | [remote-workspace-transport.md](docs/architecture/remote-workspace-transport.md)、[remote-workspaces.md](docs/features/remote-workspaces.md) | +| 远程控制 | 手机端 mobile web,或飞书 / Telegram / 微信 Bot,通过 Remote Connect relay 驱动 Desktop 或 CLI 宿主上的会话 | [`src/mobile-web`](src/mobile-web/AGENTS.md)、[services-integrations](src/crates/services/services-integrations/AGENTS.md) 的 `remote_connect`、[relay-service](src/crates/services/relay-service/AGENTS.md) | +| 多端互控(Peer Device Mode) | 同账号的一台设备成为另一台的数据平面:控制端外壳仍在本地,invoke 和事件来自 peer | [peer-device-mode.md](docs/architecture/peer-device-mode.md)、[peer-device README](src/web-ui/src/infrastructure/peer-device/README.md) | +| Dispatch 分离任务 | 控制端把持久化任务提交到另一台 BitFun 宿主后即可断开;目标端拥有 job、session、worktree、事件日志和权限信箱 | [detached-task-dispatch.md](docs/architecture/detached-task-dispatch.md) | + +四种场景共同适用的规则: + +- 远程路径要和功能一起设计。默认 UI、进程和文件系统在同一台机器上的能力属于未完成, + 而不是“第一阶段”。 +- 不支持要显式暴露。确实无法支持时,应屏蔽入口或返回明确的不支持状态;静默回落本地、 + 假成功、空载荷和通用错误都算回归,其中回落本地还会把本地内容泄露给远端控制方。 +- 阻塞式交互必须可以远程应答。新增的权限确认、对话框和选择器都要经既有的 dialog / + 权限信箱编排送达当前操作端;只能靠桌面窗口解除的阻塞会让远程控制和 Dispatch 任务死锁。 +- 要能扛断线。远程形态会重连、按 cursor 重放并重新 hydrate,因此优先使用可恢复 cursor + 和幂等变更,不要依赖“客户端恰好在线”才存在的状态。 +- 远程工作区路径在任何客户端 OS 上都是 POSIX 路径。不得用宿主 `std::path` 语义切分或 + 拼接,也不得把控制端的路径直接拿到 peer 宿主上复用。 + +各场景的具体约束: + +- **远程工作区**:每个桌面端 Tauri 命令都必须在 + [`remote_workspace_policy.rs`](src/apps/desktop/src/api/remote_workspace_policy.rs) + 中声明策略;该文件的契约测试会拒绝没有显式策略的新命令,并禁止 `LegacyUnaudited` + 存量清单增长。 +- **远程控制**:mobile web 和 IM Bot 是通过 `RemoteCommand` wire 协议和 bot command + router / menu 触达会话的,不走 Web UI。新增或迁移会话级能力时——工作区与助手选择、 + 会话生命周期、模式、模型、审批、附件——要同步扩展这些形态,或让它们给出明确的 + 不支持回复。 +- **多端互控**:产品命令默认代理到 peer 执行。必须留在控制端的命令(窗口装饰、更新器、 + 账号身份、本地 OS 自动化)要在三份保持同步的清单中一起禁用: + [`peer_host_invoke.rs`](src/apps/desktop/src/api/peer_host_invoke.rs)、 + [`deny.rs`](src/apps/cli/src/peer_host/deny.rs) 和 + [`peer-device-adapter.ts`](src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts)。 + 改动 session、account 或 hydrate 路径前,先读 peer-device README 的 invariants。 +- **Dispatch 分离任务**:任务在目标端以 CLI delivery profile 无界面运行,没有交互宿主, + 也不保证控制端在线。控制端只是观察者,不是 runtime 或文件系统代理。不要引入依赖提交方 + 常驻的行为;dispatch 协议版本和目标端必备 capability 属于兼容契约——新的目标端要求要走 + 协商 capability,而不是默认假设。 + +改动说明中要写清楚在哪些远程场景下验证过。只跑本地测试不能作为远程行为的证据。 + +### 升级兼容性 + +用户是原地升级的,而上述远程场景经常让两个不同版本的 BitFun 连在同一条链路上。 +任何改动都必须保证已有安装在升级后无需手工修复即可继续工作。 + +- **落盘结构会被新旧两侧代码同时读取。** 配置、设置、会话、连接配置、worktree 和 + dispatch 记录:新增字段要带默认值,反序列化要保持容错,绝不重新定义或收窄已经落盘 + 字段的语义。旧数据给不出的字段,不能变成必填。 +- **不要用删除或重置用户数据的方式来“恢复”解析不了的内容。** 应保留记录、降级功能并 + 给出明确状态。凭证缺失、配置读不出、超时或主机离线,都不构成丢弃会话、工作区或连接 + 的理由;销毁性删除只能是用户的显式操作。 +- **跨版本边界要协商,不能假设。** Peer HostInvoke、dispatch 协议、relay 与 mobile web、 + IM Bot,对面都是你控制不了的构建版本。要先声明 capability 再使用——包版本相同不等于 + 行为相同——并且要让旧版本一侧留在可用路径上,而不是直接判失败。 +- **改名就是一次迁移。** 在所有受支持的对端都不可能再发送旧名称、旧 id 或旧结构之前, + 必须继续兼容读取;被改名对象所引用的数据(vault 条目、工作区指针)要一并迁移。 +- **用测试证明。** 要覆盖旧数据反序列化和旧载荷往返,而不只是新结构。只验证当前代码 + 自己写出的数据,不算升级兼容性覆盖。 ### Agent loop 行为 diff --git a/AGENTS.md b/AGENTS.md index 3494a30943..0fb6666158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,15 @@ Repository rule: **keep product logic platform-agnostic, then expose it through 5. Workspace Rust dependencies own compatible versions, not broad capability unions. Each crate must select the dependency features it actually uses; keep test-only features in dev-dependencies and attach feature-gated service - capabilities to the owning crate feature. `tokio/full` is forbidden in the - root workspace and workspace members. + capabilities to the owning crate feature. Disable third-party defaults in + `[workspace.dependencies]` when they are not part of every consumer's + contract; members inherit that policy and add only their needed slices. For + internal crates whose guarded `default` is empty, do not repeat + `default-features = false` on every edge. Narrow consumers of an intentional + compatibility default, such as ACP, must still disable it explicitly. + Manifests copied into a standalone Docker build context must keep explicit + versions and default policy because they cannot inherit the workspace root. + `tokio/full` is forbidden in the root workspace and workspace members. ## Layered Module Index @@ -145,14 +152,94 @@ await api.invoke('your_command', { request: { ... } }); - Desktop-only host adapters belong in `src/apps/desktop`, then flow through typed capability interfaces and, when event delivery is needed, the production transport adapter. - In shared core, avoid host-specific APIs such as `tauri::AppHandle`; use shared abstractions such as `bitfun_events::EventEmitter`. -### Remote compatibility - -- When adding features, consider remote workspace and remote control synchronization support from the start. Local-only behavior can silently leave remote scenarios incomplete. -- If a feature cannot reasonably support remote workspaces, gate it or show a clear unsupported-state message instead of letting it fail with a generic error. -- Every desktop Tauri command must declare its remote-workspace policy in - `src/apps/desktop/src/api/remote_workspace_policy.rs`; the contract test there - rejects new commands without an explicit policy and forbids growing the - legacy-unaudited backlog. +### Remote scenarios + +BitFun is not a local-only desktop app. The workspace, the runtime that executes +a turn, and the person driving it can each sit on a different machine. Treat the +four scenarios below as first-class targets of every change, not as a later port. + +| Scenario | What it means | Design entry point | +|---|---|---| +| Remote workspace | The active workspace lives on an SSH host, a jump-host chain, or a Docker container; files, terminal, search, and Agent subprocesses must execute there | [remote-workspace-transport.md](docs/architecture/remote-workspace-transport.md), [remote-workspaces.md](docs/features/remote-workspaces.md) | +| Remote control | Mobile web, or a Feishu / Telegram / WeChat bot, drives a session on a Desktop or CLI host through the Remote Connect relay | [`src/mobile-web`](src/mobile-web/AGENTS.md), `remote_connect` in [services-integrations](src/crates/services/services-integrations/AGENTS.md), [relay-service](src/crates/services/relay-service/AGENTS.md) | +| Peer Device Mode | One same-account device becomes the data plane of another: the controller shell stays local, invokes and events come from the peer | [peer-device-mode.md](docs/architecture/peer-device-mode.md), [peer-device README](src/web-ui/src/infrastructure/peer-device/README.md) | +| Detached Dispatch | A controller submits a durable job to another BitFun host and may then disconnect; the target owns the job, session, worktree, event log, and permission mailbox | [detached-task-dispatch.md](docs/architecture/detached-task-dispatch.md) | + +Rules that apply to all four: + +- Design the remote path together with the feature. A capability that assumes UI, + process, and filesystem share one machine is incomplete, not "phase one". +- Degrade loudly. When a scenario cannot be supported, gate the entry point or + return a clear unsupported state. Silent local fallback, fake success, empty + payloads, and generic errors are all regressions; local fallback additionally + leaks local content to a remote controller. +- Keep blocking interaction answerable from a distance. New permission prompts, + dialogs, and pickers must reach the driving surface through the existing dialog + and permission-mailbox orchestration. A turn that only the desktop window can + unblock deadlocks remote control and dispatch jobs. +- Survive disconnect. Remote surfaces reconnect, replay by cursor, and re-hydrate, + so prefer resumable cursors and idempotent mutations over state that exists only + while a client happens to be attached. +- Remote workspace paths are POSIX on every client OS. Do not split or join them + with host `std::path` semantics, and do not reuse a controller-side path on a + peer host. + +Per-scenario obligations: + +- **Remote workspace**: every desktop Tauri command declares its policy in + [`remote_workspace_policy.rs`](src/apps/desktop/src/api/remote_workspace_policy.rs). + The contract test there rejects new commands without an explicit policy and + forbids growing the `LegacyUnaudited` backlog. +- **Remote control**: mobile web and IM bots reach sessions through the + `RemoteCommand` wire protocol and the bot command router / menu, not through the + Web UI. When a session-level capability is added or moved — workspace or + assistant selection, session lifecycle, mode, model, approval, attachment — + extend those surfaces or make them answer with an explicit unsupported reply. +- **Peer Device Mode**: product commands are proxied to the peer by default. A + command that must stay on the controller (window chrome, updater, account + identity, local OS automation) has to be denied in all three lists that are kept + in sync: [`peer_host_invoke.rs`](src/apps/desktop/src/api/peer_host_invoke.rs), + [`deny.rs`](src/apps/cli/src/peer_host/deny.rs), and + [`peer-device-adapter.ts`](src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts). + Read the peer-device README invariants before changing session, account, or + hydrate paths. +- **Detached Dispatch**: jobs run headless on the target under the CLI delivery + profile, with no interactive host and no guaranteed controller connection. The + controller is an observer, never a runtime or filesystem proxy. Do not add + behavior that requires a live submitter, and treat the dispatch protocol version + and required target capabilities as a compatibility contract — a new target-side + requirement needs a negotiated capability, not an assumption. + +State which remote scenarios a change was exercised in. Local-only tests are not +evidence of remote behavior. + +### Upgrade compatibility + +Users upgrade in place, and the remote scenarios above routinely put two +different BitFun versions on the same connection. Every change must keep +existing installs working without manual repair. + +- **Persisted shapes are read by older and newer code.** Config, settings, + sessions, connection profiles, worktree and dispatch records: add fields with + defaults, keep deserialization tolerant, and never repurpose or narrow the + meaning of a field that is already on disk. A field old data cannot supply + must not become required. +- **Never delete or reset user data to recover from something you cannot + parse.** Keep the record, degrade the feature, and surface a clear state. + Missing credentials, an unreadable profile, a timeout, or an offline host are + not reasons to drop a session, workspace, or connection. Destructive removal + stays an explicit user action. +- **Cross-version boundaries negotiate; they do not assume.** Peer HostInvoke, + the dispatch protocol, relay and mobile web, and IM bots all talk to a build + you do not control. Advertise a capability and check it before using it — + package version equality is not evidence of behavior — and keep the older + side on a working path instead of failing it. +- **A rename is a migration.** Keep reading the old name, id, or record shape + until no supported peer can still send it, and migrate referenced data + (vault entries, workspace pointers) together with the thing being renamed. +- **Prove it with tests.** Cover legacy deserialization and an old-payload + round trip, not just the new shape. A test that only exercises data written + by the current code is not upgrade coverage. ### Agent loop behavior diff --git a/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json index 08ffb33d52..56699761f0 100644 --- a/BitFun-Installer/package-lock.json +++ b/BitFun-Installer/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-installer", - "version": "0.2.17", + "version": "0.2.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-installer", - "version": "0.2.17", + "version": "0.2.18", "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/BitFun-Installer/package.json b/BitFun-Installer/package.json index 7f9d1759c0..0b34ccd0ca 100644 --- a/BitFun-Installer/package.json +++ b/BitFun-Installer/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-installer", - "version": "0.2.17", + "version": "0.2.18", "private": true, "type": "module", "description": "BitFun Custom Installer - Modern branded installation experience", diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index 55d8d2525a..dddb579468 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-installer" -version = "0.2.17" +version = "0.2.18" authors = ["BitFun Team"] edition = "2021" description = "BitFun Custom Installer - Modern branded installation experience" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 993d6a26c0..5cb107a01a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,7 @@ pnpm install ```bash # Desktop (recommended for daily development) pnpm run desktop:dev # full hot-reload: Vite HMR + Rust auto-rebuild & restart +pnpm run prepare:dsh-profile # optional: compile the DeepSeek Harness bridge for local sessions # Desktop (lightweight preview, no Rust auto-rebuild) pnpm run desktop:preview:debug # reuse pre-built binary + Vite HMR; Rust changes require manual restart diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index 6b6b268c18..444f59fb3b 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -55,6 +55,7 @@ pnpm install ```bash # Desktop(日常开发推荐) pnpm run desktop:dev # 完整热更新:Vite HMR + Rust 自动重编译并重启 +pnpm run prepare:dsh-profile # 可选:编译 DeepSeek Harness 桥,供本地会话使用 # Desktop(轻量预览,无 Rust 自动重编译) pnpm run desktop:preview:debug # 复用预构建二进制 + Vite HMR;Rust 改动需手动重启 diff --git a/Cargo.lock b/Cargo.lock index ede08203e7..5b8850e053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,7 +85,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e" dependencies = [ "anyhow", - "derive_more 2.1.1", + "derive_more", "schemars 1.2.2", "serde", "serde_json", @@ -550,6 +550,17 @@ dependencies = [ "zbus 5.18.0", ] +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -748,7 +759,7 @@ dependencies = [ [[package]] name = "bitfun-acp" -version = "0.2.17" +version = "0.2.18" dependencies = [ "agent-client-protocol", "async-trait", @@ -759,11 +770,14 @@ dependencies = [ "bitfun-events", "chrono", "dashmap", + "dirs 6.0.0", "futures", "log", + "semver", "serde", "serde_json", "sha2", + "tar", "tokio", "tokio-util", "uuid", @@ -771,11 +785,11 @@ dependencies = [ [[package]] name = "bitfun-agent-content" -version = "0.2.17" +version = "0.2.18" [[package]] name = "bitfun-agent-runtime" -version = "0.2.17" +version = "0.2.18" dependencies = [ "async-trait", "bitfun-agent-stream", @@ -801,12 +815,13 @@ dependencies = [ [[package]] name = "bitfun-agent-runtime-ipc" -version = "0.2.17" +version = "0.2.18" dependencies = [ "async-trait", "bitfun-events", "bitfun-product-domains", "bitfun-runtime-ports", + "bitfun-transport", "dunce", "fs2", "serde", @@ -821,7 +836,7 @@ dependencies = [ [[package]] name = "bitfun-agent-stream" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -841,7 +856,7 @@ dependencies = [ [[package]] name = "bitfun-agent-tools" -version = "0.2.17" +version = "0.2.18" dependencies = [ "async-trait", "bitfun-core-types", @@ -854,7 +869,7 @@ dependencies = [ [[package]] name = "bitfun-ai-adapters" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "apple-native-keyring-store", @@ -873,7 +888,7 @@ dependencies = [ "keyring-core", "libc", "log", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "sha2", @@ -888,7 +903,7 @@ dependencies = [ [[package]] name = "bitfun-app-server" -version = "0.2.17" +version = "0.2.18" dependencies = [ "agent-client-protocol", "anyhow", @@ -897,23 +912,19 @@ dependencies = [ "bitfun-app-server-client", "bitfun-app-server-protocol", "bitfun-core", - "bitfun-events", "bitfun-product-domains", "bitfun-runtime-ports", - "futures", "log", "serde", "serde_json", "tokio", - "tokio-util", "tracing", - "ts-rs", "url", ] [[package]] name = "bitfun-app-server-client" -version = "0.2.17" +version = "0.2.18" dependencies = [ "agent-client-protocol", "anyhow", @@ -924,7 +935,7 @@ dependencies = [ [[package]] name = "bitfun-app-server-protocol" -version = "0.2.17" +version = "0.2.18" dependencies = [ "agent-client-protocol", "bitfun-core-types", @@ -938,7 +949,7 @@ dependencies = [ [[package]] name = "bitfun-claude-code-adapter" -version = "0.2.17" +version = "0.2.18" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -957,7 +968,7 @@ dependencies = [ [[package]] name = "bitfun-cli" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "arboard", @@ -980,7 +991,6 @@ dependencies = [ "chrono", "clap", "crossterm", - "dashmap", "dirs 6.0.0", "dunce", "flate2", @@ -991,17 +1001,17 @@ dependencies = [ "libc", "minisign-verify", "once_cell", - "portable-pty", + "portable-pty 0.8.1", + "portable-pty 0.9.0", "pulldown-cmark", "ratatui", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "sha2", "shlex 1.3.0", "similar", "syntect", - "syntect-tui", "tar", "tempfile", "thiserror 2.0.19", @@ -1018,7 +1028,7 @@ dependencies = [ [[package]] name = "bitfun-codex-adapter" -version = "0.2.17" +version = "0.2.18" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -1035,7 +1045,7 @@ dependencies = [ [[package]] name = "bitfun-core" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -1049,6 +1059,7 @@ dependencies = [ "bitfun-claude-code-adapter", "bitfun-codex-adapter", "bitfun-core-types", + "bitfun-dsh-adapter", "bitfun-events", "bitfun-external-sources", "bitfun-harness", @@ -1061,7 +1072,6 @@ dependencies = [ "bitfun-services-core", "bitfun-services-integrations", "bitfun-tool-packs", - "bitfun-transport", "chrono", "chrono-tz", "cron", @@ -1074,21 +1084,14 @@ dependencies = [ "fs2", "futures", "hex", - "htmd", "image 0.25.10", "include_dir", "indexmap 2.14.0", - "lazy_static", - "legible", "log", "md5", - "napi-derive-ohos", - "napi-ohos", "notify", - "once_cell", - "parking_lot", "regex", - "reqwest 0.13.4", + "reqwest", "rusqlite", "semver", "serde", @@ -1096,25 +1099,22 @@ dependencies = [ "serde_yaml", "sha2", "similar", - "tauri", "tempfile", "terminal-core", "thiserror 2.0.19", "tokio", - "tokio-stream", "tokio-tungstenite", "tokio-util", "tool-runtime", "tower-http", "ts-rs", "unic-langid", - "urlencoding", "uuid", ] [[package]] name = "bitfun-core-types" -version = "0.2.17" +version = "0.2.18" dependencies = [ "serde", "serde_json", @@ -1123,12 +1123,11 @@ dependencies = [ [[package]] name = "bitfun-desktop" -version = "0.2.17" +version = "0.2.18" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", "anyhow", - "arboard", "async-trait", "atspi", "axum", @@ -1164,14 +1163,12 @@ dependencies = [ "leptess", "libc", "log", - "napi-derive-ohos", - "napi-ohos", "objc2 0.6.4", "objc2-app-kit", "objc2-foundation", "objc2-vision", "regex", - "reqwest 0.13.4", + "reqwest", "resvg", "screenshots", "semver", @@ -1183,10 +1180,15 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-dialog", "tauri-plugin-fs", - "tauri-plugin-global-shortcut", "tauri-plugin-log", + "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tauri-plugin-window-state", "tempfile", "thiserror 2.0.19", "time", @@ -1203,9 +1205,26 @@ dependencies = [ "zstd", ] +[[package]] +name = "bitfun-dsh-adapter" +version = "0.2.18" +dependencies = [ + "async-trait", + "bitfun-plugin-runtime-client", + "bitfun-product-domains", + "bitfun-runtime-ports", + "hex", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "urlencoding", +] + [[package]] name = "bitfun-events" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -1219,7 +1238,7 @@ dependencies = [ [[package]] name = "bitfun-external-sources" -version = "0.2.17" +version = "0.2.18" dependencies = [ "bitfun-product-domains", "futures", @@ -1229,7 +1248,7 @@ dependencies = [ [[package]] name = "bitfun-harness" -version = "0.2.17" +version = "0.2.18" dependencies = [ "async-trait", "thiserror 2.0.19", @@ -1238,7 +1257,7 @@ dependencies = [ [[package]] name = "bitfun-miniapp-market-server" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "axum", @@ -1250,7 +1269,7 @@ dependencies = [ [[package]] name = "bitfun-miniapp-market-service" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "axum", @@ -1260,7 +1279,7 @@ dependencies = [ "hex", "image 0.25.10", "rand 0.8.7", - "reqwest 0.13.4", + "reqwest", "semver", "serde", "serde_json", @@ -1274,14 +1293,13 @@ dependencies = [ "tower-http", "tracing", "url", - "urlencoding", "uuid", "zip 4.6.1", ] [[package]] name = "bitfun-opencode-adapter" -version = "0.2.17" +version = "0.2.18" dependencies = [ "async-trait", "bitfun-plugin-runtime-client", @@ -1309,18 +1327,17 @@ dependencies = [ [[package]] name = "bitfun-page-function-runtime" -version = "0.2.17" +version = "0.2.18" dependencies = [ "rquickjs", "serde", "serde_json", "thiserror 2.0.19", - "tokio", ] [[package]] name = "bitfun-plugin-runtime-client" -version = "0.2.17" +version = "0.2.18" dependencies = [ "async-trait", "bitfun-runtime-ports", @@ -1329,7 +1346,7 @@ dependencies = [ [[package]] name = "bitfun-product-capabilities" -version = "0.2.17" +version = "0.2.18" dependencies = [ "async-trait", "bitfun-agent-runtime", @@ -1342,7 +1359,7 @@ dependencies = [ [[package]] name = "bitfun-product-domains" -version = "0.2.17" +version = "0.2.18" dependencies = [ "dirs 6.0.0", "hex", @@ -1359,7 +1376,7 @@ dependencies = [ [[package]] name = "bitfun-relay-server" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "axum", @@ -1376,7 +1393,7 @@ dependencies = [ [[package]] name = "bitfun-relay-service" -version = "0.2.17" +version = "0.2.18" dependencies = [ "aes-gcm", "anyhow", @@ -1405,7 +1422,7 @@ dependencies = [ [[package]] name = "bitfun-runtime-ports" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -1420,7 +1437,7 @@ dependencies = [ [[package]] name = "bitfun-runtime-services" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -1435,7 +1452,7 @@ dependencies = [ [[package]] name = "bitfun-sdk-host" -version = "0.2.17" +version = "0.2.18" dependencies = [ "async-trait", "bitfun-agent-runtime", @@ -1448,18 +1465,21 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "ts-rs", "uuid", ] [[package]] name = "bitfun-sdk-host-app" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", "bitfun-agent-runtime", "bitfun-core", "bitfun-sdk-host", + "bitfun-services-core", + "bitfun-transport", "futures-util", "rustls", "serde_json", @@ -1472,7 +1492,7 @@ dependencies = [ [[package]] name = "bitfun-server" -version = "0.2.17" +version = "0.2.18" dependencies = [ "agent-client-protocol", "anyhow", @@ -1498,7 +1518,7 @@ dependencies = [ [[package]] name = "bitfun-services-core" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -1534,7 +1554,7 @@ dependencies = [ [[package]] name = "bitfun-services-integrations" -version = "0.2.17" +version = "0.2.18" dependencies = [ "aes", "aes-gcm", @@ -1572,7 +1592,8 @@ dependencies = [ "oxc", "qrcode", "rand 0.8.7", - "reqwest 0.13.4", + "regex", + "reqwest", "rmcp", "russh", "russh-keys", @@ -1589,7 +1610,6 @@ dependencies = [ "sse-stream", "ssh_config", "tar", - "tauri", "tempfile", "terminal-core", "thiserror 2.0.19", @@ -1610,7 +1630,7 @@ dependencies = [ [[package]] name = "bitfun-skin-market-server" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "axum", @@ -1622,7 +1642,7 @@ dependencies = [ [[package]] name = "bitfun-skin-market-service" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "axum", @@ -1632,7 +1652,7 @@ dependencies = [ "hex", "hmac", "image 0.25.10", - "reqwest 0.13.4", + "reqwest", "semver", "serde", "serde_json", @@ -1651,7 +1671,7 @@ dependencies = [ [[package]] name = "bitfun-static-hook-support" -version = "0.2.17" +version = "0.2.18" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -1664,7 +1684,7 @@ dependencies = [ [[package]] name = "bitfun-tool-call-jsonrepair" -version = "0.2.17" +version = "0.2.18" dependencies = [ "serde", "serde_json", @@ -1672,23 +1692,25 @@ dependencies = [ [[package]] name = "bitfun-tool-packs" -version = "0.2.17" +version = "0.2.18" [[package]] name = "bitfun-transport" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", "bitfun-events", "log", + "serde", "serde_json", "tauri", + "thiserror 2.0.19", ] [[package]] name = "bitfun-webdriver" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "axum", @@ -1714,18 +1736,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -1804,30 +1814,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "borsh" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases 0.2.2", -] - -[[package]] -name = "borsh-derive" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" -dependencies = [ - "once_cell", - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "brotli" version = "8.0.4" @@ -1875,40 +1861,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byte-unit" -version = "5.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0" -dependencies = [ - "rust_decimal", - "schemars 1.2.2", - "serde", - "utf8-width", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "bytemuck" version = "1.25.2" @@ -2370,12 +2322,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.6.0" @@ -2394,15 +2340,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "convert_case" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cookie" version = "0.18.1" @@ -2656,23 +2593,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros 0.6.1", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -2740,38 +2660,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "ctor" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - [[package]] name = "ctor" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9bb72bb94fdc1bd619f4c18cc91ecf6302aeb333d31b3c6ec0bb841cd920209" -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "ctr" version = "0.9.2" @@ -2814,12 +2708,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "custom_error" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8a51dd197fa6ba5b4dc98a990a43cc13693c23eb0089ebb0fcc1f04152bca6" - [[package]] name = "dark-light" version = "1.1.1" @@ -3048,19 +2936,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -3248,21 +3123,6 @@ dependencies = [ "tendril 0.4.3", ] -[[package]] -name = "dom_query" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" -dependencies = [ - "bit-set", - "cssparser 0.36.0", - "foldhash 0.2.0", - "html5ever 0.38.0", - "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.1", -] - [[package]] name = "dom_query" version = "0.28.0" @@ -3320,21 +3180,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -3654,17 +3499,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fast-float2" version = "0.2.3" @@ -3916,12 +3750,6 @@ dependencies = [ "libc", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futf" version = "0.1.5" @@ -4186,26 +4014,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width 0.2.0", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -4215,7 +4023,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -4387,28 +4195,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] -name = "global-hotkey" -version = "0.7.0" +name = "globset" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" -dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit", - "once_cell", - "serde", - "thiserror 2.0.19", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - -[[package]] -name = "globset" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -4728,18 +4518,6 @@ dependencies = [ "phf 0.13.1", ] -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - [[package]] name = "html5ever" version = "0.36.1" @@ -4909,9 +4687,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png 0.17.16", @@ -5165,6 +4943,15 @@ dependencies = [ "cfb 0.7.3", ] +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb 0.14.0", +] + [[package]] name = "inotify" version = "0.11.4" @@ -5238,6 +5025,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -5485,12 +5281,24 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" dependencies = [ - "jsonptr", + "jsonptr 0.6.3", "serde", "serde_json", "thiserror 1.0.69", ] +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr 0.7.1", + "serde", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -5501,6 +5309,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "jsonrpcmsg" version = "0.1.2" @@ -5566,18 +5384,6 @@ dependencies = [ "libc", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors 0.24.0", -] - [[package]] name = "kurbo" version = "0.13.1" @@ -5732,16 +5538,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "libloading" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - [[package]] name = "libm" version = "0.2.16" @@ -5796,12 +5592,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5845,9 +5635,6 @@ name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -dependencies = [ - "value-bag", -] [[package]] name = "lopdf" @@ -5912,6 +5699,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.4", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "mac_address" version = "1.1.8" @@ -5931,20 +5732,6 @@ dependencies = [ "libc", ] -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", -] - [[package]] name = "markup5ever" version = "0.36.1" @@ -5990,17 +5777,6 @@ dependencies = [ "xml5ever", ] -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "matchers" version = "0.2.0" @@ -6010,12 +5786,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "matchit" version = "0.8.4" @@ -6071,6 +5841,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.7.1" @@ -6144,7 +5923,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -6160,9 +5939,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -6173,67 +5952,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.60.2", -] - -[[package]] -name = "napi-build-ohos" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617bcd176a3dc3d96c0acad112a482f592242d6fba6773a9eb8d90e042a35260" - -[[package]] -name = "napi-derive-backend-ohos" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b06bf282ab442eed04c89f8a833bd06d4cf22f18654f50d848449db9e94438a" -dependencies = [ - "convert_case 0.11.0", - "proc-macro2", - "quote", - "semver", - "syn 2.0.119", -] - -[[package]] -name = "napi-derive-ohos" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419b1721d9f21d76c1a5cb38e2628211cb786b5efb9e1dd2fc049ba1cb938e47" -dependencies = [ - "convert_case 0.11.0", - "ctor 1.0.11", - "napi-derive-backend-ohos", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "napi-ohos" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe1a014105134b7889e85f5ab6f8ec5240db43ed37e6c4a38bd7b4a164b345e0" -dependencies = [ - "bitflags 2.11.1", - "ctor 1.0.11", - "futures", - "napi-build-ohos", - "napi-sys-ohos", - "nohash-hasher", - "rustc-hash 2.1.3", -] - -[[package]] -name = "napi-sys-ohos" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266964b97e57f3ca023ed13e7c78d4c8f3900aa00466bbeb8cb7746653cabc5f" -dependencies = [ - "libloading 0.9.0", + "windows-sys 0.61.2", ] [[package]] @@ -6251,6 +5973,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -6295,6 +6023,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.26.4" @@ -6333,18 +6075,6 @@ dependencies = [ "memoffset 0.9.1", ] -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - [[package]] name = "nom" version = "7.1.3" @@ -6388,6 +6118,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.18.0", +] + [[package]] name = "notify-types" version = "2.1.0" @@ -6596,26 +6340,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.1", "block2 0.6.2", - "libc", "objc2 0.6.4", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-av-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" -dependencies = [ - "objc2 0.6.4", "objc2-foundation", ] @@ -6630,35 +6357,12 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-core-audio" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" -dependencies = [ - "dispatch2", - "objc2 0.6.4", - "objc2-core-audio-types", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-core-audio-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" -dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", -] - [[package]] name = "objc2-core-data" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.11.1", "objc2 0.6.4", "objc2-foundation", ] @@ -6709,31 +6413,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-core-media" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" -dependencies = [ - "bitflags 2.11.1", - "dispatch2", - "objc2 0.6.4", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-video", -] - -[[package]] -name = "objc2-core-ml" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201b055e6acfa0f9f15568255d3f03ce2b54bc86d1814442dc69138e36813e18" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation", -] - [[package]] name = "objc2-core-text" version = "0.3.2" @@ -6746,19 +6425,6 @@ dependencies = [ "objc2-core-graphics", ] -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -6787,17 +6453,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-image-io" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" -dependencies = [ - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", -] - [[package]] name = "objc2-io-kit" version = "0.3.2" @@ -6824,13 +6479,15 @@ dependencies = [ ] [[package]] -name = "objc2-javascript-core" +name = "objc2-osa-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ + "bitflags 2.11.1", "objc2 0.6.4", - "objc2-core-foundation", + "objc2-app-kit", + "objc2-foundation", ] [[package]] @@ -6845,17 +6502,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -6893,17 +6539,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfc194758a2d5d7540b1ad283bfb9ca318ec608991892326e95b428230b2689b" dependencies = [ - "block2 0.6.2", "objc2 0.6.4", - "objc2-av-foundation", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-media", - "objc2-core-ml", - "objc2-core-video", "objc2-foundation", - "objc2-image-io", ] [[package]] @@ -6918,162 +6556,6 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", - "objc2-javascript-core", - "objc2-security", -] - -[[package]] -name = "ohos-arkui-binding" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9352f0db69271fc23fb565ad1f034c2118a9ea9c727ece344834b451fe2c2865" -dependencies = [ - "bitflags 2.11.1", - "napi-ohos", - "napi-sys-ohos", - "ohos-arkui-sys", - "ohos-xcomponent-binding", - "ohos-xcomponent-sys", - "ohos_enum_macro 0.0.2", -] - -[[package]] -name = "ohos-arkui-sys" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a19176486bbe46d33830aca4213b07bb46c745200118e57106639db4962e1c3" -dependencies = [ - "napi-sys-ohos", -] - -[[package]] -name = "ohos-display-binding" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157fe09232fd0af55ed9207d30b47a5f5ff7f84a0673a3b47f65acb7bbc9b8a9" -dependencies = [ - "ohos-display-sys", - "ohos_enum_macro 0.0.1", -] - -[[package]] -name = "ohos-display-sys" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe9e1bba4d94159d0a65f1b4e748c148d4bce7930912c424522693f1837dad77" - -[[package]] -name = "ohos-ime-binding" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b69f3e94816c243aaba0e55f230f87edc19e33c0f9397bfa56c64acb54f776a" -dependencies = [ - "ohos-input-method-sys", - "ohos_enum_macro 0.1.0", -] - -[[package]] -name = "ohos-input-method-sys" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225be105b169f45a2bca86498988c1877568d82542c013b345d605eb915e48ae" - -[[package]] -name = "ohos-resource-manager-binding" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33793d5fc06cf356b3d25aa6228785e62c9f2a0e913a2c285b1eb8efb7185b5e" -dependencies = [ - "napi-ohos", - "ohos-resource-manager-sys", -] - -[[package]] -name = "ohos-resource-manager-sys" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97398d3f86b80279f062bd872e04be851fc9e0eb1dba4dcaabef872201517ff6" -dependencies = [ - "napi-sys-ohos", -] - -[[package]] -name = "ohos-web-binding" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1ed1f2ba96b92179128f6b18ea5a0dd879210e483f7dcab51200219cbfa4a58" -dependencies = [ - "bitflags 2.11.1", - "ohos-web-sys", - "ohos_enum_macro 0.0.2", -] - -[[package]] -name = "ohos-web-sys" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa646f3223c2bc90a400641c3a5cda4b350387ffda1a904b8bfb3db92b81ecbc" - -[[package]] -name = "ohos-xcomponent-binding" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5a9bb0fd157dc702863fe6b5e15b91a4ffdc08b4c47d57d080adcc73c36fa8" -dependencies = [ - "napi-derive-ohos", - "napi-ohos", - "napi-sys-ohos", - "ohos-xcomponent-sys", - "ohos_enum_macro 0.0.2", - "raw-window-handle", -] - -[[package]] -name = "ohos-xcomponent-sys" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5613209329ff61bc8695871ef985d57fc59ea4d55263e2c2395d8810d063575e" -dependencies = [ - "ohos-arkui-sys", -] - -[[package]] -name = "ohos_enum_macro" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0ee769776099c824423d1686e21b5fd13b464d374d60f7cc25c48c1c836ede1" -dependencies = [ - "convert_case 0.6.0", - "proc-macro2", - "quote", - "regex", - "syn 2.0.119", -] - -[[package]] -name = "ohos_enum_macro" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4bacd60b52be93024c446f27645d795ab71eb2243bb4c5d87b6238a35341fbd" -dependencies = [ - "convert_case 0.6.0", - "proc-macro2", - "quote", - "regex", - "syn 2.0.119", -] - -[[package]] -name = "ohos_enum_macro" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc1544b50988de3d636f971135e66c40479ef68e30ef09b15fb911763aab077" -dependencies = [ - "convert_case 0.6.0", - "proc-macro2", - "quote", - "regex", - "syn 2.0.119", ] [[package]] @@ -7127,33 +6609,6 @@ dependencies = [ "libc", ] -[[package]] -name = "openharmony-ability" -version = "0.3.0" -source = "git+https://github.com/harmony-contrib/openharmony-ability.git?rev=295a276a699ba2352addc69b8cacb6acd3be6ab1#295a276a699ba2352addc69b8cacb6acd3be6ab1" -dependencies = [ - "futures-channel", - "http", - "napi-derive-ohos", - "napi-ohos", - "ohos-arkui-binding", - "ohos-display-binding", - "ohos-ime-binding", - "ohos-resource-manager-binding", - "ohos-web-binding", - "ohos-xcomponent-binding", -] - -[[package]] -name = "openharmony-ability-derive" -version = "0.3.0" -source = "git+https://github.com/harmony-contrib/openharmony-ability.git?rev=295a276a699ba2352addc69b8cacb6acd3be6ab1#295a276a699ba2352addc69b8cacb6acd3be6ab1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "openssl-probe" version = "0.1.6" @@ -7214,6 +6669,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "outref" version = "0.5.2" @@ -7808,36 +7277,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.12.1" @@ -7869,26 +7308,6 @@ dependencies = [ "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf_codegen" version = "0.13.1" @@ -7899,36 +7318,6 @@ dependencies = [ "phf_shared 0.13.1", ] -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.7", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.7", -] - [[package]] name = "phf_generator" version = "0.13.1" @@ -7949,33 +7338,6 @@ dependencies = [ "phf_shared 0.14.0", ] -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "phf_macros" version = "0.13.1" @@ -8002,40 +7364,13 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.3", -] - [[package]] name = "phf_shared" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" dependencies = [ - "siphasher 1.0.3", + "siphasher", ] [[package]] @@ -8044,7 +7379,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.3", + "siphasher", ] [[package]] @@ -8053,7 +7388,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ - "siphasher 1.0.3", + "siphasher", ] [[package]] @@ -8244,9 +7579,30 @@ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" name = "portable-atomic-util" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" dependencies = [ - "portable-atomic", + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", ] [[package]] @@ -8374,12 +7730,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.107" @@ -8389,26 +7739,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "pulldown-cmark" version = "0.11.3" @@ -8416,18 +7746,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" dependencies = [ "bitflags 2.11.1", - "getopts", "memchr", - "pulldown-cmark-escape", "unicase", ] -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - [[package]] name = "pulp" version = "0.22.3" @@ -8540,7 +7862,7 @@ dependencies = [ "getrandom 0.4.3", "lru-slab", "rand 0.10.2", - "rand_pcg 0.10.2", + "rand_pcg", "ring", "rustc-hash 2.1.3", "rustls", @@ -8587,26 +7909,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg 0.2.1", -] - [[package]] name = "rand" version = "0.8.7" @@ -8639,16 +7941,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -8669,15 +7961,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -8702,24 +7985,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "rand_pcg" version = "0.10.2" @@ -8751,7 +8016,6 @@ dependencies = [ "lru", "paste", "strum 0.26.3", - "time", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -8895,50 +8159,6 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -8978,7 +8198,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -9006,6 +8226,30 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rgb" version = "0.8.53" @@ -9029,35 +8273,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rmcp" version = "1.8.0" @@ -9072,7 +8287,7 @@ dependencies = [ "oauth2", "pastey", "pin-project-lite", - "reqwest 0.13.4", + "reqwest", "rmcp-macros", "schemars 1.2.2", "serde", @@ -9354,23 +8569,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rust_decimal" -version = "1.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.7", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "rustc-hash" version = "1.1.0" @@ -9641,12 +8839,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sec1" version = "0.7.3" @@ -9703,24 +8895,6 @@ dependencies = [ "libc", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.35.0" @@ -9729,33 +8903,14 @@ checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" dependencies = [ "bitflags 2.11.1", "cssparser 0.36.0", - "derive_more 2.1.1", - "log", - "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", - "precomputed-hash", - "rustc-hash 2.1.3", - "servo_arc 0.4.3", - "smallvec", -] - -[[package]] -name = "selectors" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" -dependencies = [ - "bitflags 2.11.1", - "cssparser 0.36.0", - "derive_more 2.1.1", + "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen 0.13.1", + "phf_codegen", "precomputed-hash", "rustc-hash 2.1.3", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] @@ -9767,14 +8922,14 @@ checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ "bitflags 2.11.1", "cssparser 0.37.0", - "derive_more 2.1.1", + "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen 0.13.1", + "phf_codegen", "precomputed-hash", "rustc-hash 2.1.3", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] @@ -9993,6 +9148,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serial2" version = "0.2.37" @@ -10026,16 +9223,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -10213,12 +9400,6 @@ dependencies = [ "log", ] -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - [[package]] name = "siphasher" version = "1.0.3" @@ -10524,19 +9705,6 @@ dependencies = [ "float-cmp", ] -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -10550,18 +9718,6 @@ dependencies = [ "serde", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" @@ -10647,7 +9803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ "kurbo", - "siphasher 1.0.3", + "siphasher", ] [[package]] @@ -10721,30 +9877,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", "flate2", "fnv", "once_cell", "onig", - "plist", "regex-syntax", "serde", "serde_derive", - "serde_json", "thiserror 2.0.19", "walkdir", - "yaml-rust", -] - -[[package]] -name = "syntect-tui" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24486acfb54bfcae77f45784cb59254e14454949a44f9d0b62613a699619c210" -dependencies = [ - "custom_error", - "ratatui", - "syntect", ] [[package]] @@ -10783,14 +9924,16 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.3" -source = "git+https://github.com/richerfu/tao?branch=feat-ohos-webview#9d51be24a7189f80407f12c322c95fccde98e215" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9fa4618f999c4249db1681cba0a19b890718f274de7fa93c445d46bd3a8a999" dependencies = [ "bitflags 2.11.1", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.25.0", "crossbeam-channel", + "dbus", "dispatch2", "dlopen2", "dpi", @@ -10801,18 +9944,17 @@ dependencies = [ "libc", "log", "ndk", + "ndk-context", "ndk-sys", "objc2 0.6.4", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", "once_cell", - "openharmony-ability", - "openharmony-ability-derive", "parking_lot", "percent-encoding", "raw-window-handle", - "tao-macros 0.1.3", + "tao-macros", "unicode-segmentation", "url", "windows 0.61.3", @@ -10821,16 +9963,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "git+https://github.com/richerfu/tao?branch=feat-ohos-webview#9d51be24a7189f80407f12c322c95fccde98e215" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tao-macros" version = "0.1.4" @@ -10842,12 +9974,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tar" version = "0.4.46" @@ -10867,8 +9993,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.8.5" -source = "git+https://github.com/richerfu/tauri?branch=feat%2Fopen-harmony#c818cc1de4eae70a14273cf8a4f81283987a42b7" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -10891,12 +10018,10 @@ dependencies = [ "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", - "openharmony-ability", - "openharmony-ability-derive", "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "serde_repr", @@ -10906,12 +10031,11 @@ dependencies = [ "tauri-macros", "tauri-runtime", "tauri-runtime-wry", - "tauri-utils 2.7.0", + "tauri-utils", "thiserror 2.0.19", "tokio", "tray-icon", "url", - "urlpattern", "webkit2gtk", "webview2-com", "window-vibrancy", @@ -10920,35 +10044,35 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.4.1" -source = "git+https://github.com/richerfu/tauri?branch=feat%2Fopen-harmony#c818cc1de4eae70a14273cf8a4f81283987a42b7" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", "dirs 6.0.0", "glob", "heck 0.5.0", - "json-patch", - "napi-build-ohos", + "json-patch 3.0.1", "schemars 0.8.22", "semver", "serde", "serde_json", - "tauri-utils 2.7.0", + "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.4.0" -source = "git+https://github.com/richerfu/tauri?branch=feat%2Fopen-harmony#c818cc1de4eae70a14273cf8a4f81283987a42b7" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", "ico", - "json-patch", + "json-patch 3.0.1", "plist", "png 0.17.16", "proc-macro2", @@ -10958,7 +10082,7 @@ dependencies = [ "serde_json", "sha2", "syn 2.0.119", - "tauri-utils 2.7.0", + "tauri-utils", "thiserror 2.0.19", "time", "url", @@ -10968,15 +10092,16 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.4.0" -source = "git+https://github.com/richerfu/tauri?branch=feat%2Fopen-harmony#c818cc1de4eae70a14273cf8a4f81283987a42b7" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", "tauri-codegen", - "tauri-utils 2.7.0", + "tauri-utils", ] [[package]] @@ -10991,19 +10116,53 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "tauri-utils 2.9.3", + "tauri-utils", "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + [[package]] name = "tauri-plugin-fs" -version = "2.4.4" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ "anyhow", "dunce", "glob", + "log", + "objc2-foundation", "percent-encoding", "schemars 0.8.22", "serde", @@ -11011,54 +10170,57 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "tauri-utils 2.9.3", + "tauri-utils", "thiserror 2.0.19", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "url", ] [[package]] -name = "tauri-plugin-global-shortcut" -version = "2.3.1" +name = "tauri-plugin-log" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" dependencies = [ - "global-hotkey", + "android_logger", + "fern", "log", + "objc2 0.6.4", + "objc2-foundation", "serde", "serde_json", + "serde_repr", + "swift-rs", "tauri", "tauri-plugin", "thiserror 2.0.19", + "time", ] [[package]] -name = "tauri-plugin-log" -version = "2.7.1" +name = "tauri-plugin-notification" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5709c792b8630290b5d9811a1f8fe983dd925fc87c7fc7f4923616458cd00b6" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" dependencies = [ - "android_logger", - "byte-unit", - "fern", "log", - "objc2 0.6.4", - "objc2-foundation", + "notify-rust", + "rand 0.9.5", "serde", "serde_json", "serde_repr", - "swift-rs", "tauri", "tauri-plugin", "thiserror 2.0.19", "time", + "url", ] [[package]] name = "tauri-plugin-opener" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", @@ -11076,10 +10238,74 @@ dependencies = [ "zbus 5.18.0", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.19", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus 5.18.0", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer 0.19.0", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.19", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", +] + [[package]] name = "tauri-runtime" -version = "2.8.0" -source = "git+https://github.com/richerfu/tauri?branch=feat%2Fopen-harmony#c818cc1de4eae70a14273cf8a4f81283987a42b7" +version = "2.11.3" +source = "git+https://github.com/tauri-apps/tauri.git?rev=7cc68e74ff6981f5c50a52a67d56c5eb2d227188#7cc68e74ff6981f5c50a52a67d56c5eb2d227188" dependencies = [ "cookie", "dpi", @@ -11089,11 +10315,10 @@ dependencies = [ "objc2 0.6.4", "objc2-ui-kit", "objc2-web-kit", - "openharmony-ability", "raw-window-handle", "serde", "serde_json", - "tauri-utils 2.7.0", + "tauri-utils", "thiserror 2.0.19", "url", "webkit2gtk", @@ -11103,8 +10328,8 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.8.1" -source = "git+https://github.com/richerfu/tauri?branch=feat%2Fopen-harmony#c818cc1de4eae70a14273cf8a4f81283987a42b7" +version = "2.11.4" +source = "git+https://github.com/tauri-apps/tauri.git?rev=7cc68e74ff6981f5c50a52a67d56c5eb2d227188#7cc68e74ff6981f5c50a52a67d56c5eb2d227188" dependencies = [ "gtk", "http", @@ -11112,14 +10337,13 @@ dependencies = [ "log", "objc2 0.6.4", "objc2-app-kit", - "objc2-foundation", "once_cell", "percent-encoding", "raw-window-handle", "softbuffer", "tao", "tauri-runtime", - "tauri-utils 2.7.0", + "tauri-utils", "url", "webkit2gtk", "webview2-com", @@ -11127,60 +10351,21 @@ dependencies = [ "wry", ] -[[package]] -name = "tauri-utils" -version = "2.7.0" -source = "git+https://github.com/richerfu/tauri?branch=feat%2Fopen-harmony#c818cc1de4eae70a14273cf8a4f81283987a42b7" -dependencies = [ - "anyhow", - "brotli", - "cargo_metadata", - "ctor 0.2.9", - "dunce", - "glob", - "html5ever 0.29.1", - "http", - "infer", - "json-patch", - "kuchikiki", - "log", - "memchr", - "phf 0.11.3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.19", - "toml 0.9.12+spec-1.1.0", - "url", - "urlpattern", - "uuid", - "walkdir", -] - [[package]] name = "tauri-utils" version = "2.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +source = "git+https://github.com/tauri-apps/tauri.git?rev=7cc68e74ff6981f5c50a52a67d56c5eb2d227188#7cc68e74ff6981f5c50a52a67d56c5eb2d227188" dependencies = [ "anyhow", + "brotli", "cargo_metadata", - "ctor 0.8.0", - "dom_query 0.27.0", + "ctor", + "dom_query 0.28.0", "dunce", "glob", - "html5ever 0.29.1", "http", - "infer", - "json-patch", - "kuchikiki", + "infer 0.22.0", + "json-patch 4.2.0", "log", "memchr", "phf 0.13.1", @@ -11200,6 +10385,7 @@ dependencies = [ "url", "urlpattern", "uuid", + "walkdir", ] [[package]] @@ -11213,6 +10399,17 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.19", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -11257,7 +10454,7 @@ dependencies = [ [[package]] name = "terminal-core" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -11270,7 +10467,8 @@ dependencies = [ "futures", "libc", "log", - "portable-pty", + "portable-pty 0.8.1", + "portable-pty 0.9.0", "rand 0.8.7", "serde", "serde_json", @@ -11282,6 +10480,15 @@ dependencies = [ "win32job", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "tesseract-plumbing" version = "0.8.0" @@ -11678,7 +10885,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tool-runtime" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anydoc", "bitfun-agent-tools", @@ -11837,9 +11044,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs 6.0.0", @@ -11851,10 +11058,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11948,27 +11155,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - [[package]] name = "unic-langid" version = "0.9.6" @@ -11987,26 +11173,6 @@ dependencies = [ "tinystr", ] -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - [[package]] name = "unicase" version = "2.9.0" @@ -12146,13 +11312,13 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -12171,7 +11337,7 @@ dependencies = [ "pico-args", "roxmltree", "simplecss", - "siphasher 1.0.3", + "siphasher", "strict-num", "svgtypes", "tiny-skia-path", @@ -12184,12 +11350,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-width" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -12220,12 +11380,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "value-bag" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" - [[package]] name = "vcpkg" version = "0.2.15" @@ -12302,12 +11456,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -12378,19 +11526,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -12505,9 +11640,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -13398,8 +12533,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.55.1" -source = "git+https://github.com/richerfu/wry#ae8b21b592ff65074381cb8a71f0fd1bb787fac0" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1730ab21a962e7ff44df17d20804572beee66d998afc7fcbc3f846a3a12dbda7" dependencies = [ "base64 0.22.1", "block2 0.6.2", @@ -13423,13 +12559,11 @@ dependencies = [ "objc2-ui-kit", "objc2-web-kit", "once_cell", - "openharmony-ability", - "openharmony-ability-derive", "percent-encoding", "raw-window-handle", "sha2", "soup3", - "tao-macros 0.1.4", + "tao-macros", "thiserror 2.0.19", "url", "webkit2gtk", @@ -13441,15 +12575,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" @@ -13573,15 +12698,6 @@ dependencies = [ "lzma-sys", ] -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "yoke" version = "0.8.3" @@ -14050,13 +13166,3 @@ dependencies = [ "syn 2.0.119", "winnow 1.0.4", ] - -[[patch.unused]] -name = "openharmony-ability" -version = "1.0.0-beta.0" -source = "git+https://github.com/harmony-contrib/openharmony-ability.git#7603be9ef4a0bf7c0eaa5d23e821f21b28254d5c" - -[[patch.unused]] -name = "openharmony-ability-derive" -version = "1.0.0-beta.0" -source = "git+https://github.com/harmony-contrib/openharmony-ability.git#7603be9ef4a0bf7c0eaa5d23e821f21b28254d5c" diff --git a/Cargo.toml b/Cargo.toml index 2ecc9115a2..5747a1b31a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "src/crates/assembly/external-sources", "src/crates/adapters/ai-adapters", "src/crates/adapters/opencode-adapter", + "src/crates/adapters/dsh-adapter", "src/crates/adapters/claude-code-adapter", "src/crates/adapters/codex-adapter", "src/crates/adapters/static-hook-support", @@ -54,7 +55,7 @@ resolver = "2" # Shared package metadata — single source of truth for version [workspace.package] -version = "0.2.17" # x-release-please-version +version = "0.2.18" # x-release-please-version authors = ["BitFun Team"] edition = "2021" @@ -79,10 +80,10 @@ undocumented_unsafe_blocks = "warn" [workspace.dependencies] # Async runtime tokio = { version = "1.52", default-features = false } -tokio-stream = "0.1.18" +tokio-stream = { version = "0.1.18", default-features = false } tokio-util = "0.7.18" async-trait = "0.1.89" -futures = "0.3.31" +futures = { version = "0.3.31", default-features = false, features = ["std"] } futures-util = "0.3.31" # Serialization @@ -102,12 +103,12 @@ thiserror = "2" # Logging log = "0.4" -tracing = "0.1" +tracing = { version = "0.1", default-features = false, features = ["std"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Utilities uuid = { version = "1.0", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde", "clock"] } +chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "std"] } chrono-tz = "0.10.4" cron = "0.15.0" # Tauri 2.9.2 utilities conflict with time 0.3.48 on current stable Rust. @@ -133,7 +134,7 @@ encoding_rs = "0.8.35" url = "2" # HTTP client -reqwest = { version = "0.13.4", default-features = false, features = ["http2", "json", "stream", "multipart", "query", "form"] } +reqwest = { version = "0.13.4", default-features = false } semver = "1.0" # Debug Log HTTP Server @@ -171,9 +172,8 @@ clap = { version = "4.6.1", features = ["derive"] } crossterm = "0.28" ratatui = "0.29" unicode-width = "0.2" -pulldown-cmark = "0.11" -syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } -syntect-tui = "3.0" +pulldown-cmark = { version = "0.11", default-features = false } +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] } once_cell = "1" libc = "0.2" arboard = "3" @@ -195,7 +195,7 @@ similar = "2.5" urlencoding = "2.1" oxc = { version = "0.138.0", default-features = false, features = ["ast_visit", "codegen", "semantic", "transformer"] } oxc-parse = { package = "oxc", version = "0.138.0", default-features = false } -rquickjs = { version = "0.9", default-features = false, features = ["classes", "properties", "macro"] } +sqlx = { version = "0.8", default-features = false } # Tauri (desktop only) tauri = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony", features = [] } @@ -224,10 +224,10 @@ foreign-types = "0.5" dispatch = "0.2" block2 = "0.6" objc2 = "0.6" -objc2-foundation = "0.3" -objc2-app-kit = "0.3" -objc2-vision = { version = "0.3.2", features = ["VNRecognizeTextRequest", "VNRequest", "VNObservation", "VNRequestHandler", "VNUtils", "VNTypes", "objc2-core-foundation"] } -objc2-web-kit = { version = "0.3", features = ["WKWebView", "WKSnapshotConfiguration", "WKPDFConfiguration", "block2", "objc2-app-kit"] } +objc2-foundation = { version = "0.3", default-features = false } +objc2-app-kit = { version = "0.3", default-features = false } +objc2-vision = { version = "0.3.2", default-features = false } +objc2-web-kit = { version = "0.3", default-features = false } tempfile = "3" webview2-com = "0.38" windows = "0.61" @@ -257,7 +257,7 @@ local-ip-address = "0.6" hostname = "0.4" # QR code generation -qrcode = "0.14" +qrcode = { version = "0.14", default-features = false } # WebSocket client tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] } diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000000..eb5be9ff77 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,13 @@ +# Deploy runbooks + +Production origin restore and product deploys for the current OpenBitFun host +(`ssh lwb`). + +| Directory | Use | +| --- | --- | +| [openbitfun-host/](openbitfun-host/README.md) | New-host restore: website, release mirror, Relay Nginx, New API boundary | +| [miniapp-market/](miniapp-market/README.md) | MiniApp market only | +| [skin-market/](skin-market/README.md) | Skin market only | + +An Agent that is asked to move or rebuild the server starts at +`openbitfun-host/AGENTS.md`. diff --git a/deploy/openbitfun-host/AGENTS.md b/deploy/openbitfun-host/AGENTS.md new file mode 100644 index 0000000000..faf33e5ff5 --- /dev/null +++ b/deploy/openbitfun-host/AGENTS.md @@ -0,0 +1,14 @@ +# OpenBitFun production host + +This directory is the restore entry for the current production origin +(`ssh lwb`). Read [README.md](README.md) before changing cron, Nginx, the +release mirror, Relay, the website, New API, or either market. + +This is a **move of an existing origin**, not a green-field install. Do not +re-run the export/import steps against the live `lwb` host. Do not invent a +second copy of `scripts/openbitfun-release-sync.sh`. Cron must run the in-repo +script from the BitFun checkout. + +MiniApp and Skin markets have their own runbooks. Do not mix their checkouts, +secrets, or Compose projects with this host index. Do not generate new market +secrets when the old `market.env` files still exist. diff --git a/deploy/openbitfun-host/README.md b/deploy/openbitfun-host/README.md new file mode 100644 index 0000000000..4288347a15 --- /dev/null +++ b/deploy/openbitfun-host/README.md @@ -0,0 +1,487 @@ +# OpenBitFun 生产源站恢复手册 + +给换机后的人和 AI Agent:clone BitFun 之后按本文把当前 `ssh lwb` 这台源站 +搬到新机器。命令默认从 BitFun 仓库根目录执行。 + +这是 **已有源站搬迁**,不是从零装一套空服务。不要对仍在服务的 `lwb` 重跑 +clone、`crontab` 整表安装、市场 first-install 或空 volume 的 `deploy.sh`。 + +不要再发明一份脱离仓库的同步脚本副本。历史上的 +`/root/repos/BitFun-AutoUpdate/` 只是旧主机上的运行拷贝,换机后不要重建。 +当前源站只靠 cron,不必恢复 webhook listener。 + +## Agent 先读这个 + +1. 根目录 `AGENTS.md`,再读本文件。 +2. MiniApp / Skin 分别只走它们自己的手册,不要从本目录重建那两个容器。 +3. 不要 `cat`、下载或打印 `/etc/bitfun-*/market.env`、New API 数据目录、账号密码。 +4. 不要在 `/root/repos/BitFun` 或 `/root/repos/BitFun-Website` 上跑 `git clean`。 + 共享 BitFun checkout 上也禁止无确认的 `git reset --hard`(与 MiniApp 手册一致)。 + 该 checkout 里可能有 Relay 的未跟踪 `.env`。 +5. 本机 Nginx 只听 80。HTTPS 在前面的云 WAF / 负载均衡终止,不要在这台机器上 + 擅自申请 Let's Encrypt 或改 443。 +6. 未知 Host 必须落到 `00-default-server.conf` 的 404,不能落到 New API 或 Relay。 +7. 每步失败立刻停。先做旧机只读导出,再在新机导入。 + +## 这台机器上有什么 + +| 产品 | 公网 | 源站 | 仓库内入口 | +| --- | --- | --- | --- | +| 官网 + 下载页 | `https://openbitfun.com/` 、`/download` | Nginx → `BitFun-Website/dist` | 本文「官网」 | +| Release 镜像 | `https://openbitfun.com/release/` | cron → `/srv/bitfun-release` | 本文「Release 镜像」;脚本在 `scripts/openbitfun-release-sync.sh` | +| Relay | `https://remote.openbit.fun/relay` | `bitfun-relay:9700` | `src/apps/relay-server/README.md` + 本文 Nginx | +| MiniApp 市场 | `https://market.openbitfun.com/miniapp/` | `127.0.0.1:9710` | [../miniapp-market/README.md](../miniapp-market/README.md) | +| Skin 市场 | `https://market.openbitfun.com/skin/` | `127.0.0.1:9720` | [../skin-market/README.md](../skin-market/README.md) | +| New API | `https://api.openbit.fun/` | `0.0.0.0:33292` | **不在 BitFun 仓库**。见本文「New API」 | + +当前生产 SSH 别名是 `lwb`(root)。下文旧机用 `OLD_HOST=lwb`,新机用 +`NEW_HOST`。换机后可以把 `lwb` 指到新主机,但导出/导入期间必须同时能 SSH +到两台。 + +生产路径约定: + +| 路径 | 用途 | +| --- | --- | +| `/root/repos/BitFun` | BitFun checkout。Relay 静态页和同步脚本从这里读 | +| `/root/repos/BitFun-Website` | 官网独立仓库 `GCWing/BitFun-Website` | +| `/srv/bitfun-release` | GitHub Release 镜像(禁止放进 Website `dist/`) | +| `/srv/bitfun-miniapp-market` | MiniApp 专用 checkout / 数据 / 备份 | +| `/srv/bitfun-skin-market` | Skin 专用 checkout / 数据 / 备份 | +| `/etc/bitfun-miniapp-market/market.env` | MiniApp secrets,`root:root` `0600` | +| `/etc/bitfun-skin-market/market.env` | Skin secrets,`root:root` `0600` | +| `/root/repos/new_api_bak` | New API 镜像 tar + `data/` 卷。BitFun 不管 | + +Git remote 不要混用: + +- **换机新 clone** `https://github.com/GCWing/BitFun.git` 之后,唯一 remote 是 + `origin`,跟踪 `origin/main`。 +- **当前仍在服务的 `lwb`** 是 `origin=bobleer/BitFun`、 + `upstream=GCWing/BitFun`。只在这台旧机上才使用 `upstream`。 + +脚本里的 `OPENBITFUN_BASE_URL` 写死为 `https://openbitfun.com/release`。 +域名变了要改 `scripts/openbitfun-release-sync.sh`,不能只改 Nginx。 + +## A. 旧机只读导出 + +在能 SSH 到旧机的机器上执行。默认 `OLD_HOST=lwb`。导出目录在旧机 +`/root/bitfun-host-export`,不要写进 Website `dist/` 或 BitFun checkout。 + +```bash +OLD_HOST=lwb +ssh "$OLD_HOST" 'set -eu +EXPORT=/root/bitfun-host-export +rm -rf "$EXPORT" +install -d -m 0700 "$EXPORT" + +git -C /root/repos/BitFun remote -v >"$EXPORT/bitfun.remotes" +git -C /root/repos/BitFun rev-parse HEAD >"$EXPORT/bitfun.rev" +git -C /root/repos/BitFun-Website rev-parse HEAD >"$EXPORT/website.rev" +git -C /srv/bitfun-miniapp-market/app rev-parse HEAD >"$EXPORT/miniapp.rev" +git -C /srv/bitfun-skin-market/app rev-parse HEAD >"$EXPORT/skin.rev" +crontab -l >"$EXPORT/root.crontab" +uname -a >"$EXPORT/uname.txt" +docker ps --format "{{.Names}} {{.Image}} {{.Status}}" >"$EXPORT/docker.ps" +node -v >"$EXPORT/node.version" 2>/dev/null || true + +docker run --rm \ + -v relay-server_relay-db:/data \ + -v "$EXPORT":/backup \ + alpine tar czf /backup/relay-server_relay-db.tar.gz -C /data . +docker run --rm \ + -v relay-server_room-web:/data \ + -v "$EXPORT":/backup \ + alpine tar czf /backup/relay-server_room-web.tar.gz -C /data . + +test -s "$EXPORT/bitfun.rev" +test -s "$EXPORT/relay-server_relay-db.tar.gz" +test -s "$EXPORT/relay-server_room-web.tar.gz" +ls -la "$EXPORT"' +``` + +然后把导出目录、Release 镜像、市场数据、New API 和 secrets **拷到新机**。 +`rsync` 不要加 `-v` 去扫 `market.env` 内容。 + +```bash +OLD_HOST=lwb +NEW_HOST=new-openbitfun # 换成新机器 SSH 别名 +ssh "$NEW_HOST" 'install -d -m 0700 /root/bitfun-host-export /root/repos /srv' + +rsync -aH --numeric-ids "$OLD_HOST:/root/bitfun-host-export/" \ + "$NEW_HOST:/root/bitfun-host-export/" +rsync -aH --numeric-ids "$OLD_HOST:/srv/bitfun-release/" \ + "$NEW_HOST:/srv/bitfun-release/" +rsync -aH --numeric-ids \ + "$OLD_HOST:/srv/bitfun-miniapp-market/" \ + "$NEW_HOST:/srv/bitfun-miniapp-market/" +rsync -aH --numeric-ids \ + "$OLD_HOST:/srv/bitfun-skin-market/" \ + "$NEW_HOST:/srv/bitfun-skin-market/" +rsync -aH --numeric-ids \ + "$OLD_HOST:/etc/bitfun-miniapp-market/" \ + "$NEW_HOST:/etc/bitfun-miniapp-market/" +rsync -aH --numeric-ids \ + "$OLD_HOST:/etc/bitfun-skin-market/" \ + "$NEW_HOST:/etc/bitfun-skin-market/" +rsync -aH --numeric-ids \ + "$OLD_HOST:/root/repos/new_api_bak/" \ + "$NEW_HOST:/root/repos/new_api_bak/" +``` + +验收:新机上 `test -s /root/bitfun-host-export/relay-server_relay-db.tar.gz`、 +`test -d /srv/bitfun-release/0.2.18`、`test -f /etc/bitfun-miniapp-market/market.env`。 +不要 `cat` 任何 `market.env`。 + +## B. 新机导入 + +下面 `ssh` 目标都是 `$NEW_HOST`。先装 Docker、Nginx、cron,再按顺序做。 +每块做完就跑该块验证。 + +### 1. 基础软件和 Node + +生产官网构建用 **Node v20.20.2**,装在 `/root/.nvm`。非交互 SSH 默认不加载 +nvm,所以每条 `npm` 命令都要先 `source`。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y docker.io nginx cron rsync curl ca-certificates python3 +systemctl enable --now docker nginx cron +command -v docker +command -v nginx +systemctl is-active cron + +export NVM_DIR="$HOME/.nvm" +if [ ! -s "$NVM_DIR/nvm.sh" ]; then + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash +fi +# shellcheck disable=SC1091 +. "$NVM_DIR/nvm.sh" +nvm install 20.20.2 +nvm alias default 20.20.2 +node -v +test "$(node -v)" = "v20.20.2"' +``` + +### 2. BitFun checkout + +新机从 **GCWing/BitFun** clone,只用 `origin`。checkout 导出记录的 commit, +不要猜 `upstream/main`。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +BITFUN_REV="$(cat /root/bitfun-host-export/bitfun.rev)" +test -n "$BITFUN_REV" +if [ ! -d /root/repos/BitFun/.git ]; then + git clone https://github.com/GCWing/BitFun.git /root/repos/BitFun +fi +cd /root/repos/BitFun +git remote -v +git fetch origin +git checkout --detach "$BITFUN_REV" +test "$(git rev-parse HEAD)" = "$BITFUN_REV" +test -x scripts/openbitfun-release-sync.sh' +``` + +`git remote -v` 应只有 `origin → github.com/GCWing/BitFun.git`。 + +### 3. Nginx 兜底(先拆 Ubuntu default) + +新装 Nginx 自带 `sites-enabled/default`,也是 `default_server`。不删掉的话, +未知 Host 不会落到市场手册里的 404 兜底。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +rm -f /etc/nginx/sites-enabled/default +install -m 0644 \ + /root/repos/BitFun/deploy/miniapp-market/nginx-default-server.conf \ + /etc/nginx/sites-available/00-default-server.conf +ln -sfn /etc/nginx/sites-available/00-default-server.conf \ + /etc/nginx/sites-enabled/00-default-server.conf +nginx -t +systemctl reload nginx +code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -H "Host: unconfigured.openbitfun.com" http://127.0.0.1/)" +test "$code" = "404"' +``` + +### 4. Release 镜像 + +先用旧机 rsync 过来的目录(保留 0.2.14 起的多版本,供旧 Desktop / Dispatch), +再跑一次 in-repo 同步补最新版。不要对空目录只 sync 一次就当完成。 + +Windows 网页安装包文件名以 GitHub `latest.json` 的 `manual_installers` 为准 +(现在是 `BitFun_${version}_windows-x86_64-installer.exe`)。不要再写死 +`bitfun-installer.exe`。 + +`release-sync.cron` **就是这台机器的整份 root crontab**。`crontab` 该文件会 +替换所有 root cron。先备份,确认没有其它任务再装。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +test -d /srv/bitfun-release +test -f /srv/bitfun-release/downloads.json +touch /var/log/bitfun-release-sync.log +chmod 644 /var/log/bitfun-release-sync.log +crontab -l > /root/bitfun-host-export/new-host.crontab.bak 2>/dev/null || true +crontab /root/repos/BitFun/deploy/openbitfun-host/release-sync.cron +crontab -l +/root/repos/BitFun/scripts/openbitfun-release-sync.sh +python3 -c "import json; print(json.load(open(\"/srv/bitfun-release/downloads.json\"))[\"version\"])"' +``` + +`downloads.json` 的 `version` 必须等于 +`https://github.com/GCWing/BitFun/releases/latest/download/latest.json` 的 +`version`。日志里若再出现 `Failed to download bitfun-installer.exe`,说明跑到了 +旧脚本,停下来改 cron,不要手工改清单。 + +锁文件默认是 `/var/lock/bitfun-release-sync.lock`,不要再指向 +`/root/repos/BitFun-AutoUpdate/sync.lock`,也不要放进 `/srv/bitfun-release` +(该目录由 Nginx `/release/` 对外提供)。 + +可选 beta: + +```bash +BITFUN_RELEASE_CHANNEL=beta /root/repos/BitFun/scripts/openbitfun-release-sync.sh +``` + +### 5. 官网 + +官网不在 BitFun 里。源码是 `https://github.com/GCWing/BitFun-Website`。 +`npm run build` 会清空 `dist/`,所以 Release 镜像绝不能放进 `dist/release`。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +WEBSITE_REV="$(cat /root/bitfun-host-export/website.rev)" +test -n "$WEBSITE_REV" +if [ ! -d /root/repos/BitFun-Website/.git ]; then + git clone https://github.com/GCWing/BitFun-Website.git /root/repos/BitFun-Website +fi +cd /root/repos/BitFun-Website +git fetch origin +git checkout --detach "$WEBSITE_REV" +export NVM_DIR="$HOME/.nvm" +# shellcheck disable=SC1091 +. "$NVM_DIR/nvm.sh" +nvm use 20.20.2 +npm install +npm run build +test -f dist/index.html +install -m 0644 \ + /root/repos/BitFun/deploy/openbitfun-host/nginx-openbit.fun.conf \ + /etc/nginx/sites-available/openbit.fun +ln -sfn /etc/nginx/sites-available/openbit.fun /etc/nginx/sites-enabled/openbit.fun +nginx -t +systemctl reload nginx +curl -fsS -o /dev/null -w "%{http_code}\n" \ + -H "Host: openbitfun.com" http://127.0.0.1/' +``` + +更新官网:在 Website 仓库拉代码后只跑 `npm run build`,不要动 `/srv/bitfun-release`。 + +### 6. Relay + +必须先恢复两个 volume,再启动容器。先跑 `deploy.sh` 会建空卷,账号、同步和 +Pages 资产都会丢。不要在空库上用 `relay-admin` 新建用户来“代替”恢复。 + +当前生产 `.env` 只有镜像开关(`BITFUN_USE_CN_MIRROR` 等)。新机用 +`BITFUN_MIRROR=auto`,让 `deploy.sh` 按主机网络自己选。不要写死 +`--global-mirror`。不要 `cat` 旧 `.env`。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +EXPORT=/root/bitfun-host-export +test -s "$EXPORT/relay-server_relay-db.tar.gz" +test -s "$EXPORT/relay-server_room-web.tar.gz" + +docker volume create relay-server_relay-db +docker volume create relay-server_room-web +docker run --rm \ + -v relay-server_relay-db:/data \ + -v "$EXPORT":/backup \ + alpine tar xzf /backup/relay-server_relay-db.tar.gz -C /data +docker run --rm \ + -v relay-server_room-web:/data \ + -v "$EXPORT":/backup \ + alpine tar xzf /backup/relay-server_room-web.tar.gz -C /data + +cd /root/repos/BitFun/src/apps/relay-server +BITFUN_MIRROR=auto bash deploy.sh +install -m 0644 \ + /root/repos/BitFun/deploy/openbitfun-host/nginx-remote.openbit.fun.conf \ + /etc/nginx/sites-available/remote.openbit.fun +ln -sfn /etc/nginx/sites-available/remote.openbit.fun \ + /etc/nginx/sites-enabled/remote.openbit.fun +nginx -t +systemctl reload nginx + +curl -fsS http://127.0.0.1:9700/health +curl -fsS -o /dev/null -w "%{http_code}\n" \ + -H "Host: remote.openbit.fun" http://127.0.0.1/relay/health +docker exec bitfun-relay /app/relay-admin --db /app/data/bitfun_relay.db list-users' +``` + +`list-users` 必须能看到旧机已有账号(当前是 `bowen628`、`wgq@2012`)。 +若只有空表,停下来,不要 `add-user`。 + +### 7. MiniApp / Skin(搬迁,不是首次安装) + +不要跑市场手册里的「从 `market.env.example` 生成新 secret」。旧 +`market.env`、SQLite、artifacts 已经 rsync 过来。 + +1. 确认 `/etc/bitfun-*-market/market.env` 权限仍是 `root:root` `0600`,不要打开文件。 +2. 确认 `/srv/bitfun-*-market/{data,artifacts,backups}` 在。 +3. 专用 checkout 回到导出的 commit,再按对应手册 **只 recreate 容器**,不要 + 当绿场初始化。 +4. Skin 仍从 MiniApp 专用 checkout 的契约出发,不要用 `/root/repos/BitFun` + 当 Skin 的运行 checkout。 +5. 按市场手册安装它们自己的 Nginx 与 backup timer。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +MINIAPP_REV="$(cat /root/bitfun-host-export/miniapp.rev)" +SKIN_REV="$(cat /root/bitfun-host-export/skin.rev)" +test -n "$MINIAPP_REV" +test -n "$SKIN_REV" +stat -c "%U:%G %a %n" /etc/bitfun-miniapp-market/market.env \ + /etc/bitfun-skin-market/market.env +test -f /srv/bitfun-miniapp-market/data/market.sqlite +test -f /srv/bitfun-skin-market/data/market.sqlite +git -C /srv/bitfun-miniapp-market/app checkout --detach "$MINIAPP_REV" +git -C /srv/bitfun-skin-market/app checkout --detach "$SKIN_REV" +test "$(git -C /srv/bitfun-miniapp-market/app rev-parse HEAD)" = "$MINIAPP_REV" +test "$(git -C /srv/bitfun-skin-market/app rev-parse HEAD)" = "$SKIN_REV"' +``` + +然后只按: + +- MiniApp:[../miniapp-market/README.md](../miniapp-market/README.md) 的构建 / + recreate / 验证,不要走「初次开放市场」生成新 OAuth。 +- Skin:[../skin-market/README.md](../skin-market/README.md) 同样只 recreate。 + +### 8. New API + +这是改过的 `new-api` 镜像,**BitFun clone 恢复不了**。旧机事实: + +- 镜像:`new-api:v1.0.0-rc.14-openbitfun-mod` +- 离线包:`/root/repos/new_api_bak/new-api-v1.0.0-rc.14-openbitfun-mod.tar` +- 数据:`/root/repos/new_api_bak/data` → 容器 `/data` +- 启动:`/root/repos/new_api_bak/start.sh`(宿主 `33292` → 容器 `3000`) + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +test -f /root/repos/new_api_bak/new-api-v1.0.0-rc.14-openbitfun-mod.tar +test -d /root/repos/new_api_bak/data +/root/repos/new_api_bak/load_image.sh +/root/repos/new_api_bak/start.sh +install -m 0644 \ + /root/repos/BitFun/deploy/openbitfun-host/nginx-api.openbit.fun.conf \ + /etc/nginx/sites-available/api.openbit.fun +ln -sfn /etc/nginx/sites-available/api.openbit.fun \ + /etc/nginx/sites-enabled/api.openbit.fun +nginx -t +systemctl reload nginx +docker inspect --format "{{.State.Status}}" new-api' +``` + +没有 tar 和 data 就不要猜镜像、不要空库上线。 + +### 9. 一次性对齐本目录的 Nginx + +若前面分步已装过官网 / Relay / API vhost,这一节只用来核对文件是否与仓库一致。 +先 `nginx -t` 再 reload。市场 vhost 仍按市场手册安装,不要用本目录文件覆盖 +`market.openbitfun.com.conf`。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +rm -f /etc/nginx/sites-enabled/default +install -m 0644 \ + /root/repos/BitFun/deploy/miniapp-market/nginx-default-server.conf \ + /etc/nginx/sites-available/00-default-server.conf +ln -sfn /etc/nginx/sites-available/00-default-server.conf \ + /etc/nginx/sites-enabled/00-default-server.conf +install -m 0644 \ + /root/repos/BitFun/deploy/openbitfun-host/nginx-openbit.fun.conf \ + /etc/nginx/sites-available/openbit.fun +ln -sfn /etc/nginx/sites-available/openbit.fun /etc/nginx/sites-enabled/openbit.fun +install -m 0644 \ + /root/repos/BitFun/deploy/openbitfun-host/nginx-remote.openbit.fun.conf \ + /etc/nginx/sites-available/remote.openbit.fun +ln -sfn /etc/nginx/sites-available/remote.openbit.fun \ + /etc/nginx/sites-enabled/remote.openbit.fun +install -m 0644 \ + /root/repos/BitFun/deploy/openbitfun-host/nginx-api.openbit.fun.conf \ + /etc/nginx/sites-available/api.openbit.fun +ln -sfn /etc/nginx/sites-available/api.openbit.fun \ + /etc/nginx/sites-enabled/api.openbit.fun +nginx -t +systemctl reload nginx' +``` + +### 10. 切 DNS / WAF + +本机没有 443。把云 WAF / 负载均衡的源站指到新机器后,再跑公网验收。 + +## 整机验收 + +在源站本机用 `Host` 头打 80,不要依赖这台机器能否解析公网 DNS。 + +```bash +NEW_HOST=new-openbitfun +ssh "$NEW_HOST" 'set -eu +curl -fsS -o /dev/null -w "openbitfun.com/ %{http_code}\n" \ + -H "Host: openbitfun.com" http://127.0.0.1/ +python3 - </main`。不要默认 `reset --hard`,不要 `git clean`。 | +| 更新 Relay | 源码更新后 `cd src/apps/relay-server && BITFUN_MIRROR=auto bash deploy.sh`。账号 volume 会留下。 | +| 更新 Release 镜像脚本 | 只改仓库里的 `scripts/openbitfun-release-sync.sh`。cron 已经跑这份文件。 | +| 更新官网 | 在 `BitFun-Website` 拉代码,`source ~/.nvm/nvm.sh && nvm use 20.20.2 && npm run build`。 | +| 更新市场 | 只用对应市场手册。 | + +更新 BitFun 源码**不会**自动更新官网、New API 或市场容器。 diff --git a/deploy/openbitfun-host/nginx-api.openbit.fun.conf b/deploy/openbitfun-host/nginx-api.openbit.fun.conf new file mode 100644 index 0000000000..6235a961dc --- /dev/null +++ b/deploy/openbitfun-host/nginx-api.openbit.fun.conf @@ -0,0 +1,27 @@ +server { + listen 80; + listen [::]:80; + + server_name api.openbit.fun api.openbitfun.com; + + # Allow larger payloads for AI streaming APIs (default nginx limit is 1m) + client_max_body_size 100m; + + location / { + proxy_pass http://127.0.0.1:33292; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket / SSE support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Streaming / long timeout + proxy_buffering off; + proxy_read_timeout 1200s; + proxy_send_timeout 1200s; + } +} diff --git a/deploy/openbitfun-host/nginx-openbit.fun.conf b/deploy/openbitfun-host/nginx-openbit.fun.conf new file mode 100644 index 0000000000..55d8fe4b11 --- /dev/null +++ b/deploy/openbitfun-host/nginx-openbit.fun.conf @@ -0,0 +1,31 @@ +server { + listen 80; + listen [::]:80; + + server_name openbit.fun www.openbit.fun openbitfun.com www.openbitfun.com; + + root /root/repos/BitFun-Website/dist; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Release mirror. Lives OUTSIDE the website dist because `npm run build` + # empties dist/ and would delete the mirrored installers and manifests. + # Written by scripts/openbitfun-release-sync.sh (cron, every 10 min). + # `^~` keeps the immutable-cache regex location below from matching these. + location ^~ /release/ { + alias /srv/bitfun-release/; + autoindex off; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|mp4|webm|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + gzip_min_length 1024; +} diff --git a/deploy/openbitfun-host/nginx-remote.openbit.fun.conf b/deploy/openbitfun-host/nginx-remote.openbit.fun.conf new file mode 100644 index 0000000000..f6a5e5b509 --- /dev/null +++ b/deploy/openbitfun-host/nginx-remote.openbit.fun.conf @@ -0,0 +1,65 @@ +server { + listen 80; + listen [::]:80; + + server_name remote.openbit.fun remote.openbitfun.com; + + # ── Homepage static files (exact match, served before proxy) ── + location = / { + root /root/repos/BitFun/src/apps/relay-server/static/homepage; + try_files /index.html =404; + } + + location = /i18n.json { + root /root/repos/BitFun/src/apps/relay-server/static/homepage; + } + + location = /i18n.shared.json { + root /root/repos/BitFun/src/apps/relay-server/static/homepage; + } + + # ── Redirect /relay → /relay/ ── + location = /relay { + return 301 /relay/; + } + + # ── With /relay prefix: strip prefix, proxy to relay server ── + # For clients configured with https://remote.openbit.fun/relay + location /relay/ { + proxy_pass http://127.0.0.1:9700/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_buffering off; + proxy_read_timeout 130s; + proxy_send_timeout 130s; + proxy_connect_timeout 10s; + client_max_body_size 100m; + } + + # ── Without prefix: proxy directly to relay server ── + # For clients configured with https://remote.openbit.fun + location / { + proxy_pass http://127.0.0.1:9700; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_buffering off; + proxy_read_timeout 130s; + proxy_send_timeout 130s; + proxy_connect_timeout 10s; + client_max_body_size 100m; + } +} diff --git a/deploy/openbitfun-host/release-sync.cron b/deploy/openbitfun-host/release-sync.cron new file mode 100644 index 0000000000..b45557d510 --- /dev/null +++ b/deploy/openbitfun-host/release-sync.cron @@ -0,0 +1,6 @@ +# This file is the entire root crontab for the OpenBitFun origin. +# Installing it replaces every root cron job. Back up `crontab -l` first. +# Install with: +# crontab /root/repos/BitFun/deploy/openbitfun-host/release-sync.cron +# Requires /root/repos/BitFun checked out and /srv/bitfun-release created. +*/10 * * * * /root/repos/BitFun/scripts/openbitfun-release-sync.sh >> /var/log/bitfun-release-sync.log 2>&1 diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index bb8c5c5214..0bacb6376c 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -4,9 +4,9 @@ Agent Runtime 的模块职责见 [`agent-runtime-services-design.md`](agent-runtime-services-design.md),公开 SDK 见 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md),第三方 JS/TS 进程见 -[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md)。Rich Client 的 App Server 协议、Embedded/Shared Host -和 transport 提案见 [`app-server-architecture.md`](app-server-architecture.md)。该提案通过架构评审前,当前部署和调用路径以本文及 -已接线代码为准。 +[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md)。Rich Client 的 Embedded direct-runtime 决策、App Server 协议 +和 Shared transport 提案见 [`app-server-architecture.md`](app-server-architecture.md)。Embedded 迁移完成前,当前调用路径以本文及 +已接线代码为准;Shared transport 未通过独立评审前继续使用 v17。 ## 1. 决策与当前状态 @@ -19,7 +19,7 @@ flowchart TB Desktop["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] Web["Web UI"] --> WebAS["loopback WebSocket App Server"] TUI["Interactive TUI"] --> Backend["TuiBackend"] - Backend -->|"Embedded"| EmbeddedAS["in-process App Server"] + Backend -->|"Embedded current"| EmbeddedAS["in-process App Server"] Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] Other["Headless CLI · ACP · Peer Host · SDK Host"] --> Adapter["独立 first-party adapters"] DesktopAdapter --> API["Agent Runtime API / owner ports"] @@ -40,42 +40,52 @@ flowchart LR Runtime -. "injects Runtime API and owner ports" .-> Host ``` -两张图中的实线表示当前业务请求,虚线只表示启动期构造与依赖注入。 +Current 图只表示已经接线的业务请求路径;下方 composition 图中的虚线只表示启动期构造与依赖注入。 -### 1.2 Proposed Rich Client target +### 1.2 Approved Embedded target ```mermaid flowchart TB - Rich["Desktop GUI · Web UI · Interactive TUI"] --> Host["Rich Client Host"] - Host --> Client["App Server client"] - Client --> Transport["Host-selected Embedded / Shared transport"] - Transport --> AppServer["App Server"] - Other["Headless CLI · ACP · Peer Host"] --> Adapter["独立 first-party adapters"] - SDK["Public Agent SDK"] --> SDKHost["SDK Host adapter"] - AppServer --> API["Agent Runtime API / owner ports"] - Adapter --> API - SDKHost --> API + TUI["Embedded interactive TUI"] --> Composition["TuiBackend composition"] + Composition --> RuntimePort["TuiRuntimePort"] + RuntimePort --> Direct["DirectRuntimeTuiRuntime"] + Direct --> API["Agent Runtime API / owner ports"] + Composition --> Management["owner service/provider interfaces"] + API --> Owners["Session / Tool / Permission / MCP owners"] +``` + +这是已批准但尚未交付的 Embedded direct-runtime 目标,属于 Phase 5;在 Host 完成迁移和验证前,Current 图中的 Embedded App Server 路径保持不变。 + +### 1.3 Optional Shared App Server proposal + +```mermaid +flowchart LR + C1["Shared Rich Client 1"] --> Transport["candidate private Pipe / UDS"] + C2["Shared Rich Client 2"] --> Transport + Transport --> Host["Shared App Server Host"] + Host --> Runtime["one Agent Runtime owner"] + Runtime --> Storage["Workspace / Session storage"] ``` -该图是待评审目标,不是当前调用链。Shared App Server 只有达到 v17 的连接治理、安全、恢复、取消、限制、性能和回滚门槛后, -才可替换 compatibility transport;评审也可以决定保留 private v17 作为 Shared TUI 的物理 wire。 +这是 Phase 6 的待评审提案,不是当前 Shared TUI 的必经链路,也不改变 Current 图中的 private Runtime IPC v17。只有完成连接治理、安全、恢复、取消、限制、性能和回滚门槛后,才可评审是否替换 v17;评审也可以决定长期保留 v17。 -### 1.3 Current implementation facts +### 1.4 Current implementation facts | 范围 | 当前状态 | |---|---| -| Embedded Desktop GUI | 继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程;目标迁入同进程私有 App Server | -| Embedded interactive TUI | 已组装同进程私有 App Server,通过 in-memory transport、`AppServerClient` 和 `AppServerTuiBackend` 完成当前核心聊天、Session 与 Phase 3/4 管理面路径 | +| Embedded Desktop GUI | 当前继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程;Embedded direct Runtime 是后续 Host/infrastructure 目标,不在此处宣称已接线 | +| Embedded interactive TUI | 当前仍通过同进程私有 App Server、in-memory transport、`AppServerClient` 和 `AppServerTuiBackend`;目标切换为 backend composition,由 `DirectRuntimeTuiRuntime` 直接调用 `AgentRuntime` typed API | | Embedded Headless CLI/Peer Host | 保留各自独立 Runtime adapter、展示和断流策略;不因交互式 TUI 迁移而强制使用 App Server | | ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 | | Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 | | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | | 当前 HTTP Server | 已组装 Embedded Runtime 和 `BitfunAppServer`,每个 `/ws` 连接通过 WebSocket transport 运行一条 App Server connection;当前固定 loopback、单用户且缺少连接级身份与作用域绑定,不构成远程或多用户 Server API | | Shared local IPC | 未发布的 v17 本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI compatibility adapter;是否由 Shared App Server 替换仍待评审与等价证据 | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理由 Shared CLI Host 显式装配 App Server 的具体 `AppManagementService` 保留;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供,默认仍是 Embedded | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;当前管理面具体接线为 `SharedTuiBackend -> concrete AppManagementService -> existing product owners`,其中 `AppManagementService` 是现有 owner 上的 adapter;按 domain 拆分 owner service/provider 是 Phase 5 的后续工作;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供,默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、通用 Controller transfer 和 Session archive 同样不在当前协议中 | -因此当前交付的是 Embedded TUI App Server 与一条窄的、显式启用的 Shared TUI compatibility deployment,不是通用本机 Server。 +因此当前交付的是 Embedded TUI App Server 与一条窄的、显式启用的 Shared TUI compatibility deployment; +下一步将先把 Embedded TUI 切换为 direct Runtime adapter,不把它扩展成通用本机 Server。 具体 `EventQueue` 仍由 Core 产品装配;当前 Shared IPC 只把 TUI 必需的强类型操作和事件映射到同一个 Runtime owner, 没有公开协议承诺。是否以 App Server Shared transport 替换并删除它,由行为等价、性能、安全和回滚证据决定。 @@ -86,7 +96,8 @@ flowchart TB | Agent Runtime | 负责 Session、Turn、Tool、MCP、Permission、Hook、事件和持久化行为的既有模块 | 进程名、Server 或 SDK | | Embedded deployment | Runtime 与调用入口位于同一 Rust 进程 | 简化版 Runtime | | Shared deployment | 同一 Runtime 由一个本机进程承载,多个第一方 Client 通过私有 IPC 使用 | 新 Runtime、公开 Server 或 Agent SDK | -| Embedded App Server | 与 Rich Client Host 同进程的私有 App Server 实例和 in-memory transport | Runtime 直连、后台进程或网络 Server | +| Embedded direct Runtime | Host 通过稳定 Rust typed facade 调用同进程 Runtime owner | 第二套 Runtime、Core singleton 直连或 App Server wire | +| Embedded App Server | 当前 TUI 使用、仅作迁移前基线的同进程 App Server 实例和 in-memory transport | Embedded 的目标默认路径、后台进程或网络 Server;Phase 5 切换后删除 | | Shared App Server | 独立本机 Host 承载、由多个已认证 Rich Client 通过受控 transport 使用的 App Server | 公网 API 或每个 Client 一个 Runtime | | Agent SDK Host | 将公开 SDK 合同映射到 Runtime API 的私有进程/adapter | CLI、Shared deployment 或 Plugin Host | | Plugin Host | 运行 Node/Bun 和第三方插件代码的受监督子进程 | Agent Runtime 或 Rust IPC client | @@ -106,19 +117,26 @@ flowchart TB Desktop["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] Web["Web UI"] --> AppServer["loopback WebSocket App Server"] - EmbeddedTUI["Embedded TUI"] --> AppServer + EmbeddedTUI["Embedded TUI"] --> EmbeddedCurrent["in-process App Server · current"] + EmbeddedTUI -. "target" .-> Composition["TuiBackend composition"] + Composition --> RuntimePort["TuiRuntimePort"] + RuntimePort --> DirectRuntime["DirectRuntimeTuiRuntime"] + Composition --> Management["owner service/provider interfaces"] DesktopAdapter --> API AppServer --> API + DirectRuntime --> API SharedCompat["Shared Runtime IPC · temporary compatibility"] --> API Headless["Headless / ACP adapters"] --> API SDK["SDK Host adapter"] --> API Remote["Remote adapter"] --> API ``` -当前复用的是 Runtime API、权威事实和 owner;Web 与 Embedded TUI 额外复用 App Server wire,Shared TUI 使用 private v17,Desktop -仍使用自己的 adapter。第 1.2 节目标只有通过评审并完成迁移后才扩大 App Server 复用范围。各入口不复用 renderer、CLI 参数、SDK -wire、远程认证或平台窗口生命周期。任何新能力必须先进入既有 Runtime owner,再由 App Server 或需要它的独立 adapter 映射,禁止 -在 Embedded、Shared 或其他入口复制业务实现。 +当前复用的是 Runtime API、权威事实和 owner;Web 额外使用 App Server wire,Embedded TUI +当前使用 App Server、目标改为 direct Runtime adapter,Shared TUI 的共同 Runtime 行为使用 +private v17。TUI 管理面由 composition 按 domain 注入 owner service/provider,不随 Runtime +port 进入 Shared wire。各入口不复用 renderer、CLI 参数、SDK wire、远程认证或平台窗口生命周期。 +任何新能力必须先进入既有 Runtime 或 owner service,再由 App Server、direct adapter 或其他独立 +adapter 映射,禁止在 Embedded、Shared 或其他入口复制业务实现。 ### 3.1 Embedded 事件交付 @@ -126,9 +144,11 @@ wire、远程认证或平台窗口生命周期。任何新能力必须先进入 flowchart LR Queue["EventQueue"] --> Owner["Core product event queue owner"] Owner -->|"injects read-only AgentEventSource"| Runtime["Agent Runtime API"] - Runtime --> AppServer["Embedded App Server"] - AppServer --> TUI["Interactive TUI client"] - AppServer --> GUI["Desktop GUI client · target"] + Runtime --> AppServer["App Server · Web/Shared when needed"] + Runtime --> Direct["Embedded direct Runtime adapter"] + AppServer --> TUI["Rich Client connection"] + Direct --> TUIEmbedded["Embedded TUI client"] + AppServer --> GUI["Desktop/Web client"] Runtime --> Exec["Headless adapter"] Runtime --> Peer["Peer fanout adapter"] Runtime --> ACP["ACP adapter"] @@ -136,7 +156,8 @@ flowchart LR ``` - Core product assembly 创建事件 source,并维持旧消费队列的排空 task;第一方产品入口不再获得第二个订阅 API。 -- App Server server 从注入的 `AgentEventSource` 转发 Rich Client 权威事件;Rich Client 不得从 `AgentRuntime` 或 Core `EventQueue` 旁路订阅。 +- App Server server 从注入的 `AgentEventSource` 转发需要连接边界的 Rich Client 权威事件;这些 client 不得绕过 App Server 订阅 Core `EventQueue`。 +- Phase 5 的 Embedded direct adapter 将通过 `AgentRuntime` 提供的 typed event/Permission subscription 直接订阅同一 Runtime owner,再映射为 `TuiRuntimePort` semantic event;它不得创建第二个 Core `EventQueue` owner 或把 Runtime 内部 receiver 暴露给 TUI。 - Headless CLI、Peer Host、ACP 和 SDK Host 从各自独立 Runtime adapter 订阅,不能直接持有 Core-specific event source。 - `bitfun-core` 的旧 event-source/builder API 仅保留为 deprecated 源码兼容 facade;它们委托给同一个 Core owner,不形成第二套运行时或第一方调用路径。 - 各 adapter 继续拥有自己的失败投影:TUI 标记当前视图不可信,Headless CLI 返回非成功终态,Peer Host 中断其拥有的 turns,ACP 取消 turn 并返回协议错误,SDK Host 终结 Query 并提供 `RestartHost` recovery。 @@ -145,7 +166,7 @@ flowchart LR 持久化 replay/resume:重连后的旧 cursor 不能继续消费,client 必须重新 initialize 并执行权威 sync。 - Shared Runtime IPC v17 不复用 App Server cursor。它按自己的有界队列规则处理 lag/closed:Agent 流失效后 fail closed;Permission lag 尝试从 Runtime 的 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。任何路径都不能把流失效伪装成透明恢复。 -- 这条链路仍全部位于当前 Embedded 进程;Rich Client 使用 private in-memory transport,不增加 SDK Host、跨进程 IPC 或后台进程依赖。 +- 当前旧 Embedded App Server 链路位于 Embedded 进程;direct-runtime 目标会删除 private in-memory transport、App Server task/thread 和其 JSON-RPC 编解码,不增加 SDK Host、跨进程 IPC 或后台进程依赖。 ## 4. Process View · Level 1 @@ -284,13 +305,13 @@ sequenceDiagram - v15 为后代 transcript 读取增加 `required_settled_turn_ids` 一致性前置条件:Runtime 必须确认这些 Turn 已由 owner 持久化为终态,否则返回 `outcome_unknown`,由 TUI 在同一绝对期限内退避重试;TUI 只保留事件投影和该读屏障,不合并或重写权威 transcript。后代取消同时携带用户实际看到的 `expected_active_turn_id`,并在 owner 锁内拒绝已经切换的 Turn,避免迟到操作取消后续执行。lineage 查询和 transcript 读取是每连接至多一个的可抢占推测读取;更新的请求会取消旧读取,使后代取消和 Session 切换不会排在慢 transcript I/O 之后。该行为不放宽 controller 校验,不引入 observer 或通用多路复用。 - v16 增加只读、workspace-scoped main Agent 摘要,用于 Shared TUI 与 Runtime host 的 selector 投影一致。启动页以 Runtime 启动工作区查询且不取得 Session lease;已有 Session 由 Runtime owner 解析其执行工作区并要求当前 controller。响应只包含逻辑 ID、描述、可选固定 model ID 与 ecosystem-neutral 的 external-source 分类;发现、审批、冲突消解、generation 与执行仍由既有 Agent Registry 和 external-source owner 负责,不经 IPC 暴露安装、变更、激活、Subagent 管理或 runtime lifecycle API。 - v17 扩展原子 restore,使响应带回 Runtime Session state;增加结构化 Session usage、等待指定 Turn settlement,以及记录本地命令 - transcript turn 的 operation。它只补齐当前 Shared TUI 与 `TuiBackend` 的行为等价,没有增加 replay、observer、通用 controller - transfer、多 Session multiplex 或公开 SDK 能力。 + transcript turn 的 operation。它补齐当前 Shared TUI 的行为,并为 Phase 5 将冻结的 `TuiRuntimePort` 提供 operation 基线;没有增加 + replay、observer、通用 controller transfer、多 Session multiplex 或公开 SDK 能力。 - 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore/fork 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。fork 只接受当前 controller 的空闲 Session;无选中 Turn 时复制到最新持久化 Turn,指定 `before_turn_id` 时只复制该 Turn 之前的历史。活动 Turn 期间不能切换或 fork Session,也不能修改其名称、Agent mode 或 model;删除只作用于非当前且未被任何连接控制的 Session。 - Submit 与手动 context compaction 都使用调用方已有的 `turn_id` 标识不确定结果;若操作超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。手动 compaction 要求当前 controller 且 Session 空闲,由 Core 通过与普通对话 Turn 共用的原子准入路径创建一个可审计 maintenance Turn,并在取得所有权后读取压缩上下文:planning 阶段允许取消,atomic commit 开始后忽略晚到取消并保持 Processing 直至终态持久化完成。maintenance Turn 保留在权威 transcript 中但不进入模型上下文,live/restored payload 使用同一 compression ID 和 `applied` 事实;commit 后的持久化故障发布明确失败终态而不是遗留 Processing。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 - Session delete/rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终结果保持权威并拒绝无效目标。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试:rename 由用户恢复 Session 并核对当前值;delete 由用户重新打开 `/sessions` 核对目标是否仍存在。模型目录以及完整 Agent/Subagent 管理仍是同版本第一方产品事实,不加入 IPC;v16 的 main Agent 摘要只是 host-owned selector 所需的最小只读投影。 - 声明式上下文 reload 只失效当前 Session 的 instructions 缓存,并按目标复用 Skill Registry 刷新;它可在活动 Turn 中执行但不改写该 Turn,generation 保护保证下一条消息重建上下文。它不引入 watcher、热替换或第二套 Runtime owner。 -- v17 保留 `update current Session model` operation 及其 controller/idle/unknown-outcome 合同,但模型目录和默认值不进入该 wire。Phase 3 移除 TUI controller 对本机产品配置 owner 的直连后,`SharedTuiBackend` 通过 Host 显式注入的具体 `AppManagementService` 保留模型选择和配置;该 capability 只描述当前本机 Shared CLI adapter,不伪装成 v17 或 Remote capability。 +- v17 保留 `update current Session model` operation 及其 controller/idle/unknown-outcome 合同,但模型目录和默认值不进入该 wire。Phase 3 移除 TUI controller 对本机产品配置 owner 的直连后,`SharedTuiBackend` 通过其持有的具体 `AppManagementService` 保留模型选择和配置;Phase 5 再拆成 Host 注入的 owner service/provider adapter。该 capability 只描述当前本机 Shared CLI adapter,不伪装成 v17 或 Remote capability。 - Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。 - Windows Shared Runtime 在初始化前把自身放入 kill-on-close Job;Unix 仅在应用内优雅退出路径中通过受管子进程组回收后代。Runtime 被 `SIGTERM`、`SIGKILL` 或崩溃直接终止后的 Unix 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。 - 最后一个连接离开后等待 30 秒再退出;新连接会取消 idle 退出。退出只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。 @@ -313,7 +334,8 @@ flowchart LR | 路径 | 数据边界 | 性能约束 | |---|---|---| -| Embedded Rich Client | `AppServerClient` 通过 private in-memory transport 调用同进程 App Server | 不初始化跨进程 IPC 或后台进程;保持与 Shared 相同的 JSON-RPC、DTO、错误和事件语义,编解码成本通过测量优化而不增加直连旁路 | +| Embedded Rich Client(目标) | TUI backend composition 通过 `TuiRuntimePort` 和 owner service/provider adapter 调用同进程 Agent Runtime | 不初始化 App Server client/server、in-memory transport、跨进程 IPC 或后台进程;Runtime port 保持与 Shared/Web 相同的行为、错误、权限和事件语义,但不要求共享 JSON-RPC;管理 capability 单独按 provider 可用性验证 | +| Embedded App Server(当前迁移基线) | 迁移前由旧 `AppServerClient` 通过 private in-memory transport 调用同进程 App Server | 仅在 Phase 5 切换前有效;切换到 direct adapter 后删除,不保留回滚路径 | | Embedded non-Rich Client | Headless、ACP、Peer 和 SDK Host 的独立 adapter 以 Rust 类型调用 Runtime API | 不因 Rich Client 合同承担 App Server wire;保持各自协议和生命周期 | | Shared request | Client 将 operation 编码一次并写入一个长度前缀 frame | 请求保持 128 KiB 上限;业务层只接收类型化 operation | | Shared response/event | Server 将结果或事件编码一次后写出 | 响应/事件保持 8 MiB 上限;超限使事件流明确失效,不能无界分配 | @@ -355,56 +377,82 @@ flowchart TB ```mermaid flowchart LR - TUI["Interactive TUI"] --> Backend["TuiBackend"] - Backend -->|"Embedded"| Client["AppServerClient"] - Client --> Memory["in-memory transport"] - Memory --> AppServer["BitfunAppServer"] - Backend -->|"Shared compatibility"| IPC["adapters/agent-runtime-ipc v17"] + TUI["Interactive TUI · Phase 5 target"] --> Composition["TuiBackend composition"] + Composition --> Port["TuiRuntimePort"] + Port -->|"Embedded default"| Direct["DirectRuntimeTuiRuntime"] + Direct --> Runtime["AgentRuntime typed API"] + Composition --> Management["owner service/provider interfaces"] + Port -->|"Shared compatibility"| SharedAdapter["SharedIpcTuiRuntime"] + SharedAdapter --> IPC["adapters/agent-runtime-ipc v17"] IPC --> Handler["CLI Shared handler"] - AppServer --> Runtime["execution/agent-runtime / owners"] Handler --> Runtime ``` -CLI Host 负责命令解析、TUI 状态、错误文案、App Server 组装和 transport 生命周期;`TuiBackend` 隔离当前 Shared compatibility adapter。 -App Server 或私有 IPC 只负责协议、连接控制和类型映射;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。 -TUI 业务代码不根据部署形态复制业务分支,Shared 达到 App Server 语义等价后替换 compatibility adapter。 +第二张图只描述已批准但尚未交付的 Phase 5 TUI composition 目标。当前生产接线仍以第一张图为准: +Embedded 使用 `AppServerTuiBackend`,Shared 使用 `SharedTuiBackend -> Runtime IPC v17`。Phase 5 中, +CLI Host 将负责命令解析、TUI 状态、错误文案、direct Runtime adapter 选择和生命周期; +backend composition 将由 `TuiRuntimePort` 承载共同 Runtime 行为,并按 domain 注入 owner service/provider +接口承载管理面。Direct adapter 负责 typed request/result/event 映射,Shared adapter 负责私有 IPC +的协议与连接控制;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。TUI 业务代码 +不根据部署形态复制业务分支,Shared 是否替换 v17 仍按独立门槛决定。这里不定义一个总括性的 +`TuiManagementPort`。 - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 -- 交互式 TUI 的启动页和会话页复用 app-local `TuiBackend`;Embedded backend 使用正式 `AppServerClient`,Shared backend 暂时映射 private Runtime IPC v17。TUI 不直接依赖 Rust Runtime SDK、Core/Service owner 或 IPC operation。 +- Phase 5 完成后,交互式 TUI 的启动页和会话页将复用 app-local backend composition;Embedded Runtime 调用使用 `DirectRuntimeTuiRuntime`,Shared Runtime 调用经 `SharedIpcTuiRuntime` 映射 private Runtime IPC v17。TUI 不直接依赖 Rust Runtime SDK、Core/Service owner 或 IPC operation。 +- 管理面由 composition 按 domain 直接注入 owner-owned 的稳定 service/provider trait;原始接口若暴露内部类型,或需要 TUI DTO、权限/上下文和 capability 裁剪,才抽取薄 facade。不得创建 `TuiManagementPort` 总接口,也不得把 App Server 的 `AppManagementService` 原样迁入 CLI/TUI。 +- Web 当前独立使用自己的 loopback WebSocket App Server Host,不进入 TUI backend composition。Shared 当前只有 private Runtime IPC v17;Shared App Server 只存在于第 1.3 节的 Phase 6 candidate 图中。 - Headless CLI 和 Peer Host 使用同一 Runtime 订阅入口,但分别保留确定性退出与 Peer fanout 语义;共享订阅入口不等于共享 renderer 或产品生命周期。 -- TUI 不是 Server;Embedded Host 在同进程组装私有 App Server,是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责或 App Server 行为合同。 +- TUI 不是 Server;Phase 5 目标中的 Embedded Host 在同进程直接调用 Runtime,是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责或行为合同。 - Agent SDK Host 只服务外部 SDK 合同,不成为第一方 rich-client 的通用底座。 - Headless CLI 默认继续 Embedded;CI 或测试可保持独立进程和独立 workspace,不承担后台实例成本。 -- Tauri 仍负责窗口和桌面能力,并逐步收窄为 App Server Host adapter;未来它可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 +- Tauri 仍负责窗口和桌面能力,并逐步收窄为 product Host adapter;Embedded 产品请求可由 direct Runtime adapter 承载,需要连接边界时再使用 App Server,未来也可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 ### 5.2 Physical View +#### 5.2.1 Current production + ```mermaid flowchart TB - subgraph Embedded["默认 Embedded"] - TUI["Interactive TUI"] --> AppServer["private in-process App Server"] + subgraph Embedded["Embedded · current"] + TUI["Interactive TUI"] --> AppServer["in-process App Server"] AppServer --> Runtime["in-process Agent Runtime"] Headless["Headless / CI"] --> Runtime end - subgraph Shared["显式 --shared"] + subgraph Shared["Shared · current explicit --shared"] Clients["one or more TUI processes"] -->|"Named Pipe / UDS · current compatibility"| SharedRuntime["Shared Runtime Host process"] end Runtime --> Data["workspace + Session storage"] SharedRuntime --> Data ``` -默认交互式 TUI、Headless CLI 和 CI 保持 Embedded;交互式 TUI 通过 private in-process App Server,Headless/CI 保留独立 adapter。 -只有显式 `--shared` 的交互式 TUI 进入 Shared;同一 workspace 的两种部署互斥。多开 TUI 增加 Client 进程和有界连接, -不按 Client 数量复制 Runtime、Session owner 或 Plugin Host。 +当前默认交互式 TUI 通过同进程 App Server,Headless CLI 和 CI 通过各自 adapter 调用同进程 +Runtime。只有显式 `--shared` 的交互式 TUI 进入 Shared;同一 workspace 的两种部署互斥。 +多开 TUI 增加 Client 进程和有界连接,不按 Client 数量复制 Runtime、Session owner 或 Plugin Host。 + +#### 5.2.2 Approved Phase 5 Embedded target + +```mermaid +flowchart LR + TUI["Embedded interactive TUI"] --> Direct["Direct Runtime adapter · not yet delivered"] + Direct --> Runtime["in-process Agent Runtime"] + Runtime --> Data["workspace + Session storage"] +``` + +Phase 5 只把 Embedded interactive TUI 从当前 App Server 一次性切到 direct Runtime adapter,并删除 +旧 in-process App Server;Headless/CI 保留独立 adapter,Shared 继续使用前一张 Current 图中的 +private Runtime IPC v17。旧 App Server 不作为回滚配置保留。 + +### 5.3 Scenario (+1) · Phase 5 target: rename current Session -### 5.3 Scenario (+1) · Rename current Session +该场景将尚未交付的 Embedded direct adapter 与当前 Shared v17 行为放在同一等价目标中;它不表示 +Embedded Phase 5 已完成。 ```mermaid sequenceDiagram participant U as User participant T as TUI adapter participant B as TuiBackend - participant E as Embedded App Server adapter + participant E as Embedded direct Runtime adapter participant S as Shared Runtime IPC v17 adapter participant R as Agent Runtime @@ -412,8 +460,8 @@ sequenceDiagram T->>T: trim + require idle Session T->>B: typed TuiBackend request alt Embedded - B->>E: typed App Server request - E->>R: owner port call + B->>E: typed Runtime request + E->>R: AgentRuntime method R->>R: validate ownership + persist R-->>E: applied / failed / outcome_unknown E-->>B: mapped typed result @@ -430,14 +478,14 @@ sequenceDiagram Embedded 和 Shared 最终调用同一 `AgentRuntime::rename_session`。Runtime 只有在确认旧名称已保留时才返回明确失败;持久化恢复无法确认时,两种部署都返回 `outcome_unknown`。Shared 还会在请求已发送但权威响应丢失时返回该结果并关闭连接。用户恢复 Session、检查当前名称后再决定是否重试。 -### 5.4 Scenario (+1) · Delete an idle Session +### 5.4 Scenario (+1) · Phase 5 target: delete an idle Session ```mermaid sequenceDiagram participant U as User participant T as TUI adapter participant B as TuiBackend - participant E as Embedded App Server adapter + participant E as Embedded direct Runtime adapter participant S as Shared Runtime IPC v17 adapter participant R as Agent Runtime @@ -445,8 +493,8 @@ sequenceDiagram T->>T: reject current or active target T->>B: typed TuiBackend request alt Embedded - B->>E: typed App Server request - E->>R: owner port call + B->>E: typed Runtime request + E->>R: AgentRuntime method R->>R: existing delete owner R-->>E: applied / failed / outcome_unknown E-->>B: mapped typed result @@ -509,18 +557,18 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 | [Codex App Server](https://developers.openai.com/codex/app-server/) | App Server 为 rich client 和 remote TUI 提供 JSON-RPC;自动化继续使用 SDK;WebSocket transport 仍是实验性接口 | Rich Client 使用 App Server,自动化/公开 SDK 保持独立,并为 Shared 入口保留有界本机 transport | 不复制其完整 schema,也不把实验性远程 transport 当作已交付公网 API | | [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/typescript) | Agent loop 由长期运行的 CLI 子进程承载,并提供 `startup()` 预热以减少首次请求成本 | 长期 Shared 交互可以复用已启动进程,空闲后回收 | Embedded Rich Client 不增加子进程,多 TUI 也不映射为多个 Runtime | -三种产品说明了不同部署的有效边界:稳定 Rich Client 合同可以同时承载进程内和多客户端 transport,长期子进程适合 Shared +三种产品说明了不同部署的有效边界:稳定产品行为合同可以分别映射到进程内 direct adapter 和多客户端 transport,长期子进程适合 Shared 交互或语言 SDK,独立强类型 adapter 适合 Headless/ACP 等非 Rich Client。BitFun 采用混合部署,不把 App Server 强制成所有入口的 公共底座;当前也没有为了追赶功能表一次性增加 Session/Tool/Permission 超集。 ## 9. 不变量 - 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。 -- 当前入口使用第 1.1 节列出的 adapter;若第 1.2 节目标通过评审并迁移完成,Desktop GUI、Web UI 和交互式 TUI 才统一使用 App Server。 +- 当前入口使用第 1.1 节列出的 adapter;Embedded TUI 的下一步是从 App Server 切换到 direct Runtime adapter,Web/需要连接边界的 Rich Client 继续使用 App Server。 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 - 当前 Shared Runtime IPC 是第一方 TUI 的 private compatibility transport,不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议;是否由 App Server Shared transport 替换仍待评审。 -- Shared TUI 的 Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理暂由 CLI Host 显式装配的 App Server `AppManagementService` 承接;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供并返回 typed unsupported。这不扩展 v17,不改变 Shared Runtime 对 Session/chat 的权威性,也不能用于 Remote workspace 的控制端本机回退。MCP service 的进程状态和 tool registry 只属于当前 CLI 进程,不即时重配已经运行的 Shared Runtime Host;跨进程 MCP 管理需要单独的同步/restart contract。 -- 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 +- Shared TUI 的 Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理当前由 `SharedTuiBackend` 委托其持有的具体 `AppManagementService`;Phase 5 再拆成 CLI Host 显式装配的 owner service/provider adapter。这些管理 capability 不进入 Phase 5 的 `TuiRuntimePort` 或 v17 wire。Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供并返回 typed unsupported。这不扩展 v17,不改变 Shared Runtime 对 Session/chat 的权威性,也不能用于 Remote workspace 的控制端本机回退。MCP service 的进程状态和 tool registry 只属于当前 CLI 进程,不即时重配已经运行的 Shared Runtime Host;跨进程 MCP 管理需要单独的同步/restart contract。 +- 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;Phase 5 完成后,只有 Embedded TUI 产品请求切换为直接调用同进程 Runtime,并删除旧 Embedded App Server。Desktop direct Runtime 是独立的已批准迁移步骤,不由 TUI Phase 5 的完成状态代替;Headless CLI、ACP 与 SDK Host 继续使用各自 adapter。只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 - Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 - 未经真实 consumer 验证的接口不进入 wire;当前 wire 只包含表中列出的 Shared TUI 操作。 diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 9c9c5b28d4..671b5a611c 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -68,7 +68,7 @@ Agent Runtime API 的逻辑归属与物理部署分离:相同归属模块可 私有 SDK Host 或目标机器 Runtime 中。任何 Rust 部署都只管理自己进程树内的服务与 Node/Bun Plugin Host;不能因为多个 GUI/TUI/Remote Client 连接就复制 Runtime 状态模块,或按 Client/Workspace 创建 Plugin Host。 -Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v5 preview: +Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v6 preview: 小版本更新允许增加可选 builder hook、有默认实现的端口方法或注册表查询能力,但不得向外部可用 Rust 结构体字面量(struct literal)构造的 DTO 直接增加字段,也不得改变既有端口语义、错误分类、session / turn 标识含义或 默认 feature 依赖。任何需要调用方改写现有嵌入代码的变更,必须提升接口版本并提供兼容迁移路径。 @@ -83,6 +83,12 @@ v3 为 `AgentDialogTurnRequest` 增加来源无关的 `execution` 事实。现 v4 将活动 Turn 的文本 steer 纳入 `AgentDialogTurnPort`,复用同一个 Runtime owner 和精确 Session/Turn 身份校验;默认端口实现仍返回 `NotAvailable`,未选择该能力的 provider 不需要建立第二套 queue 或 transport。 +v6 将完整 Rust Runtime SDK 从空默认编译面移入 `agent-runtime` owner feature。现有 Rust embedder +迁移时在 `bitfun-agent-runtime` 依赖上显式选择 `features = ["agent-runtime"]`;启用后 `sdk` 模块、 +公开路径和运行时行为保持不变。只消费 DeepResearch 编号或 Hook 设置的调用方应分别选择 +`deep-research` 或 `native-hook-settings`,不需要继承完整 Runtime。仓库内最小 SDK example 通过 +`required-features = ["agent-runtime"]` 明确记录这一版本边界。 + 只要外部调用方仍必须导入 `bitfun-core`、启用 `product-full`、持有具体服务管理器、读取产品命令 注册表、理解 ACP/内部端口或依赖全局可变状态,公开 SDK 发布边界就不成立。公开 SDK 的完整 术语、能力等价和版本要求以 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 为准。 @@ -426,7 +432,7 @@ impl AgentRuntime { 该 Rust 接口是内部产品入口复用的当前形态,不是公开 Python/TypeScript SDK 的目标 API。它必须只接收 已组装的类型化部件,不负责创建 文件系统、终端、MCP、AI 客户端、Remote 提供方或产品命令。 -当前 v4 preview 接口以 message / attachment / metadata、默认标准执行目标和活动 Turn 文本 steer 作为最小输入形态;若把 +当前 v6 preview 接口以 message / attachment / metadata、默认标准执行目标和活动 Turn 文本 steer 作为最小输入形态;若把 model-round cancellation token、结构化 AgentInput 或更复杂的事件游标纳入公开 SDK, 必须分别评审 Rust Runtime SDK、SDK Host protocol 和公开 SDK API 的版本,并保留旧路径兼容。 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index 5d72ba2275..4e6024a2c4 100644 --- a/docs/architecture/agent-sdk-product-architecture.md +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -11,8 +11,12 @@ 本文只记录长期产品心智、架构边界和发布门槛。当前代码中的 `agent-runtime::sdk` 是供 BitFun 内部入口和受控 Rust 嵌入使用的低层 Rust Runtime SDK; -`interfaces/sdk-host` 与 `apps/sdk-host` 是本地协议和独立 Host 进程的实现候选。它们都不是已经发布的 -Python/TypeScript BitFun Agent SDK,也不能据此宣称公开 SDK 已交付。 +`interfaces/sdk-host` 与 `apps/sdk-host` 是本地协议和独立 Host 进程的实现候选;`sdk/typescript` 是仓库内私有的 +TypeScript 垂直切片,用于验证 managed Host、Query、显式 Session、取消、终态和错误语义。其 internal wire 类型与 +runtime validator 均由 Rust Host 协议生成,不构成公开 API。该切片对 Query 事件、终态聚合、JSON-RPC pending request +和载体写队列分别设置条目与字节预算;Windows Host 通过 `services-core` 的 kill-on-close Job Object owner 持有后代进程, +Unix managed client 通过独立进程组回收 Host 树。Python binding、平台原生 Host 包、安装与签名链路尚未交付,握手仍返回 +`not_delivered`,因此不能据此宣称公开或 Preview SDK 已交付。 ## 1. 最终决策 @@ -247,6 +251,18 @@ flowchart LR | Python/TypeScript SDK | 管理或连接匹配 Host,提供一致公开 API | 要求用户安装 `bitfun` CLI;暴露内部 wire DTO | | `CoreRuntimeOwnership` | 第一方 Rust 入口选择 Embedded/Shared,并把本机 workspace lease 注入 Coordinator | 进入公开 SDK/wire;成为 Session 单写或 Server 路由 owner | +通讯能力按“协议机械层、载体、产品协议”三层拆分: + +| 层次 | 共享 owner | 保留在使用方的职责 | +|---|---|---| +| 协议无关机械层 | `adapters/transport` 的 Rust 有界 JSON,以及同目录 TypeScript `MessageTransport`、有界 `WebSocketMessageTransport`、JSON-RPC request correlation | 不拥有业务 method、DTO、认证、Session/Query 生命周期或恢复策略;单一消费者的 length-prefix framing 留在 private IPC owner | +| 载体 | stdio、Named Pipe/UDS、WebSocket adapter 实现统一的消息收发与背压边界 | 各 Host 继续拥有 endpoint、具体 framing、Origin/认证、重连和方向性限额策略 | +| 产品协议 | SDK Host、private Runtime IPC 和 App Server 分别拥有自己的 wire 与行为合同 | 不因复用 JSON-RPC/消息机械能力而合并业务协议、controller/lease 或 Runtime owner | + +因此“复用通讯层”不等于建立万能 wire:stdio、IPC 和 WebSocket 共享有界编解码、消息收发、request correlation +等机械能力,但 SDK Host、Shared TUI IPC 与 Web App Server 仍是同级 adapter。任何载体超限、背压失败或无法确认的 +请求结果都由对应产品 adapter 投影成自己的错误与清理语义;通用通讯层不能替它们决定自动重试或生命周期。 + ### 5.2 一次 Query 的运行时序 ```mermaid diff --git a/docs/architecture/app-server-architecture.md b/docs/architecture/app-server-architecture.md index 765d7afc61..8cf3a63e62 100644 --- a/docs/architecture/app-server-architecture.md +++ b/docs/architecture/app-server-architecture.md @@ -1,42 +1,61 @@ # App Server 架构设计 -> 状态:Proposed target;关键决策与替换门槛尚待架构评审。 +> 状态:Embedded direct-runtime 已确定为下一步实现方向;Shared App Server 仍是待评审提案。 > -> 基线日期:2026-08-05。 +> 基线日期:2026-08-13。 > -> 本文记录 BitFun Rich Client 与产品后端之间的候选 App Server 边界,不是已批准的权威架构。具体 TUI 迁移阶段、接口盘点和当前缺口见 +> 本文记录 Embedded direct-runtime 决策,以及需要连接边界时的 App Server 约束和 Shared transport 提案。具体 TUI 迁移阶段、接口盘点和当前缺口见 > [`tui-app-server-decoupling-refactor-plan.md`](../plans/tui-app-server-decoupling-refactor-plan.md);Agent Runtime 的进程、所有权和实例隔离见 > [`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);产品 owner 与分层依赖见 -> [`product-architecture.md`](product-architecture.md)。评审完成前,当前调用路径以已接线代码和上述稳定架构文档为准。 - -## 1. Proposed decision - -当前首选候选是用 App Server 统一第一方 Rich Client 的产品后端接口。它尚未批准;下列约束只描述该候选被选中后的目标状态: - -- Desktop GUI、Web UI 和交互式 TUI 都是 App Server Rich Client。 -- Rich Client 的 Embedded deployment 也必须经过 App Server;它创建同进程私有 App Server,并通过私有 in-memory transport 连接。 -- Embedded 不表示直连 Runtime,也不要求独立后台进程、网络监听或跨客户端实例发现。 -- Embedded 与 Shared 复用同一 App Server client、协议版本、method、DTO、类型化错误、能力发现、事件和取消语义。 -- Embedded 与 Shared 只在 transport、App Server 实例所有权、客户端数量、连接治理和资源生命周期上不同。 -- Headless CLI/CI、ACP、Peer Host 和公开 Agent SDK 不是 Rich Client,不因该候选被强制改用 App Server;它们继续使用各自经评审的 adapter。 -- App Server 是协议适配层,不接管 Agent Runtime、Service 或 Product Domain 的业务所有权。 - -若选择该候选,不能用“Embedded 位于同一进程”作为 Rich Client 绕过 App Server 的理由,也不能用“统一 GUI/TUI 接口”把所有自动化和外部协议强制收敛到 App Server。 - -### 1.1 Alternatives under review +> [`product-architecture.md`](product-architecture.md)。迁移完成前,当前调用路径仍以已接线代码为准;Shared transport 未通过独立评审前继续使用 v17。 + +## 1. Decision and remaining proposal + +本次重构采用候选 B/C 的受限组合:**Embedded deployment 直接调用同进程 +Agent Runtime 的 typed API;需要进程间或网络边界的 Rich Client 才使用 App +Server**。因此,Rich Client 这一产品分类不再意味着所有部署都必须经过同一 +wire。 + +- Embedded Host 直接持有产品组装得到的 `AgentRuntime` 和必要的 owner/provider + facade,通过 Rust 类型调用 Runtime 方法;不创建 `BitfunAppServer`、 + `AppServerClient` 或 in-memory transport。 +- Embedded TUI 仍只依赖 app-local backend composition。TUI 不直接依赖 Runtime、Core + singleton、具体 Service 实现或私有 IPC operation;composition 内部由窄的 + `TuiRuntimePort` 承载跨 Embedded/Shared 的 Runtime 行为,并由 + `DirectRuntimeTuiRuntime` 完成 direct request/result/event/error 映射。 +- Model、Skill、Subagent、MCP、Account、Settings Sync、Worktree、External Source 和 + Hook 等管理面不进入 `TuiRuntimePort`,也不定义总括性的 `TuiManagementPort`。它们由 + backend composition 按 domain 直接注入 owner-owned 的稳定 service/provider trait;只有 + 原始接口暴露内部类型,或需要 TUI DTO、权限/上下文和 capability 裁剪时,才增加最薄的 + owner/provider facade。`AppManagementService` 继续是 App Server wiring,不原样迁移到 CLI。 +- Web UI / WebSocket Host 继续使用 App Server,因为它们需要连接、transport、 + 认证、作用域和事件转发边界。 +- Shared TUI 继续使用 private Runtime IPC v17,直到 Shared App Server 通过 + 独立的鉴权、controller/lease、恢复、取消、背压、限制、性能和回滚门槛;本次 + Embedded 重构不自动替换或删除 v17。 +- Headless CLI/CI、ACP、Peer Host 和公开 Agent SDK 继续使用各自经评审的 + adapter,不因 Rich Client 的 App Server 合同被强制改用 App Server。 +- App Server 仍是协议适配层,不接管 Agent Runtime、Service 或 Product Domain + 的业务所有权。直接 Runtime adapter 也只能调用既有 owner,不得复制业务状态。 + +Embedded 直调不是绕过安全或行为合同。它省略的是进程内无意义的 wire 和连接治理, +仍必须传递明确的 workspace、execution domain、权限和 request context,并复用同一 +Runtime owner、错误语义、事件语义和持久化规则。 + +### 1.1 Decision rationale and remaining alternatives | 候选 | 结构 | 收益 | 成本与风险 | 采用门槛 | | --- | --- | --- | --- | --- | -| A. App Server-first Rich Clients(当前首选) | Desktop、Web、Embedded/Shared TUI 复用一个 wire 与 typed client | 跨 Rich Client 合同和 fixture 最集中 | Embedded 编解码与 runtime/thread 成本;Desktop/Web 迁移面大;Shared 必须重新交付连接治理 | 真实 Desktop/TUI consumer、跨 transport parity、性能和安全门槛全部通过 | -| B. Deployment-specific product adapters | Desktop、Web、Embedded TUI、Shared TUI 各保留窄 adapter,共享 owner ports | 每个 Host 可按自身生命周期优化,迁移风险较低 | DTO、错误、恢复和行为 fixture 可能分叉;跨入口一致性需额外治理 | 证明长期重复成本低于统一 wire 成本,并建立跨 adapter 行为合同 | -| C. Shared Runtime use cases with separate wires | 提取稳定用例/结果,Embedded 使用 Rust adapter,Shared 保留 v17 或后继 wire,Web 使用 App Server | 业务语义集中,同时允许 deployment-specific framing、安全和性能 | 需要清晰区分 use-case DTO 与 wire DTO;client 不能假装同一协议 | 证明共享 use case 不泄漏 Runtime 实现,并分别验证每条 wire 的故障语义 | +| A. App Server-first Rich Clients | Desktop、Web、Embedded/Shared TUI 复用一个 wire 与 typed client | 跨 Rich Client 合同和 fixture 最集中 | Embedded 编解码与 runtime/thread 成本;Shared 必须重新交付连接治理 | 仅适用于确实需要进程/网络边界的 Host;不再作为 Embedded 默认方案 | +| B. Deployment-specific product adapters(Embedded 采用) | Embedded 直接调用 Runtime API;Web/Remote/Shared 按部署选择 transport adapter,共享 owner ports | 消除同进程编解码和 server task,保持 Host 生命周期简单 | DTO、错误、恢复和行为 fixture 可能分叉;需要统一行为合同 | Direct adapter 无 owner 复制,且通过跨入口行为等价测试 | +| C. Shared Runtime use cases with separate wires(保留) | 提取稳定用例/结果,Embedded 使用 Rust adapter,Shared 保留 v17 或后继 wire,Web 使用 App Server | 业务语义集中,同时允许 deployment-specific framing、安全和性能 | 需要清晰区分 use-case DTO 与 wire DTO;client 不能假装同一协议 | Shared transport 只有通过独立门槛后才可替换 v17 | -评审可以选择 A、B、C 或其受限组合。已有 `TuiBackend`、App Server 和 v17 是评估证据,不自动决定最终架构。 +Embedded 已选择 B/C 的受限组合,A 不再是 Embedded 默认方案。Shared 仍可选择经门槛验证的 App Server transport,或把 private v17/后继协议保留为部署专用 wire;已有 DTO 或 adapter 不能替代该评审。 ### 1.2 Costs of the preferred candidate -- Embedded Rich Client 需要承担 App Server client/server、JSON-RPC 编解码、事件队列和专用 runtime/thread 的启动、内存与延迟成本;必须以基准证明该成本可接受。 -- 迁移期会同时维护 App Server 与 Runtime IPC v17 两条 wire;新增核心用例需保持 `TuiBackend` 行为等价,不能让双写期形成两个业务 owner。 +- Embedded direct adapter 需要维护 Runtime typed API 与 TUI-facing contract 的映射;必须以行为 fixture 证明它没有复制 Session、Permission、Config 或 capability 状态。 +- 迁移前继续使用现有 Embedded App Server 作为行为基线;Phase 5 切换到 direct-runtime 后删除旧路径,不保留回滚 adapter,也不能在 direct adapter 返回 unsupported 后静默回退。 - Shared App Server 需要重新交付 v17 已有的 framing、方向性 limits、鉴权、实例身份、controller/lease、断连取消、未知结果和空闲退出,不能只复用 method/DTO。 - Desktop 迁移必须划清 controller-local capability、Tauri 生命周期和工作区 Host capability;Web/Remote 扩展还需要独立的认证、授权和多租户资源治理。 @@ -46,18 +65,30 @@ | 范围 | 当前状态 | 目标 | | --- | --- | --- | -| Embedded TUI | 已创建私有 `BitfunAppServer`,通过 in-memory transport 连接 `AppServerClient` | 完成剩余管理面迁移和行为等价验证 | -| Shared TUI | 仍通过私有 Runtime IPC v17 连接独立 Runtime Host | App Server Shared transport 达到可靠性等价后迁移 | -| Desktop GUI | 主要仍使用 Tauri command 和桌面事件投影 | Tauri 收窄为 Host adapter,产品请求统一进入 App Server | +| Embedded TUI | 当前仍通过私有 `BitfunAppServer`、in-memory transport 和 `AppServerClient` 运行 | 切换为 backend composition,由 `TuiRuntimePort`、同进程 `AgentRuntime` 和 owner service/provider 直接提供用例 | +| Shared TUI | 仍通过私有 Runtime IPC v17 连接独立 Runtime Host | 保留 v17;是否迁入 Shared App Server 由可靠性、安全、性能和回滚证据决定 | +| Desktop GUI | 主要仍使用 Tauri command 和桌面事件投影 | Embedded 时使用 direct Runtime adapter;需要连接边界时使用 App Server,Tauri 保留平台能力 | | Web Host | 当前 Server 已组装 Embedded Runtime,WebSocket 直接承载 `BitfunAppServer`;仅适用于 loopback 单用户模式 | 补齐连接身份、作用域绑定和 Host allowlist 后才能扩展部署范围 | | App Server protocol/client | 已拆为 behavior-light crate,已有版本、能力、限制、错误和部分事件恢复类型 | 补齐 Host 注入能力、可靠性语义及跨 transport 合同测试 | | App Server server | 已注册 app、agent、session、permission、TUI/workspace、git、config 和 i18n handler | 按真实 owner 和 Host 装配收窄能力,不以已存在 DTO 代替可用性证据 | -Shared TUI 继续使用 Runtime IPC 是当前 compatibility boundary。只有候选 A 获批且替换门槛通过后,才迁移或删除该 IPC;候选 B/C 可能将 private v17 或后继协议保留为受控的长期物理 wire。 +Shared TUI 继续使用 Runtime IPC 是当前 compatibility boundary。Embedded direct-runtime +迁移不改变该边界。只有 Shared App Server 通过替换门槛后,才评审迁移或删除 v17;候选 +B/C 允许将 private v17 或后继协议保留为受控的长期物理 wire。 -### 1.4 Decision and replacement gates +### 1.4 Migration and replacement gates -在满足下列门槛前,不得把候选 A 标记为 approved,也不得用 Shared App Server 替换 v17: +Embedded direct-runtime 完成迁移前必须满足: + +| 门槛 | 必需证据 | +| --- | --- | +| Typed facade 与 owner | direct adapter 只调用稳定 Runtime/owner-provider facade;不暴露内部类型,不复制 Session、Permission、Config、capability 或事件状态 | +| 上下文与能力 | workspace、execution domain、permission、remote facts 和 capability 来自真实 Host/Runtime 组装;不从 UI 猜测,不静默本机回退 | +| 行为与事件 | 请求结果、事件顺序、pending Permission、取消、`unsupported`、lag/closed 和 `outcome_unknown` 与既有行为合同等价 | +| 生命周期与性能 | 不创建 App Server client/server、in-memory transport 或额外 Runtime;订阅和任务可回收,并记录启动、延迟和内存对比 | +| 迁移与删除 | 同一 TUI fixture 覆盖 direct 与旧 App Server;升级/降级读取兼容,旧路径仅作为迁移基线并在切换后删除 | + +Shared App Server 只有满足以下连接治理门槛后才可替换 v17: | 门槛 | 必需证据 | | --- | --- | @@ -67,7 +98,7 @@ Shared TUI 继续使用 Runtime IPC 是当前 compatibility boundary。只有候 | 事件恢复 | 明确 snapshot/replay owner、连接内 cursor、跨连接是否持久化、lag/closed/invalidation 和 resync 行为 | | 取消与未知结果 | disconnect/shutdown 取消、迟到响应、operation identity、`outcome_unknown` 查询/恢复和禁止盲重试 | | Host capability | Desktop local effect 与工作区 capability 边界、provider 注入、Remote unsupported 和 Web/Remote auth 已定稿 | -| 生命周期与性能 | discovery、startup、idle exit、crash cleanup、延迟、吞吐、内存和 Embedded thread/runtime 成本有预算与测量 | +| 生命周期与性能 | discovery、startup、idle exit、crash cleanup、延迟、吞吐和内存有预算与测量 | | 迁移与回滚 | 同一第一方 consumer 完成 opt-in 双栈 parity;升级/降级和 v17 rollback 可重复验证;删除条件有明确 owner 批准 | ## 2. 问题与目标 @@ -80,14 +111,14 @@ GUI、Web 和 TUI 若分别围绕 Tauri command、WebSocket route、CLI/Core 直 - UI 组件与 Tauri、Core singleton 或私有 Runtime IPC 绑定,无法验证跨入口行为等价。 - “handler 已存在”“DTO 已生成”或“能力被硬编码为 available”被误当成端到端能力已交付。 -App Server 的目标是提供一个可版本化、可生成 client、可跨 Embedded/Shared transport 验证的 Rich Client 合同,同时保持业务 owner 平台无关。它统一的是产品后端行为,不统一 GUI/TUI renderer、布局、键位、窗口、终端或 controller-local effect。 +BitFun 的目标是让 direct adapter 与连接型 App Server adapter 共享可验证的产品后端行为合同,同时保持业务 owner 平台无关。App Server 为需要连接边界的 Host 提供可版本化、可生成 client 的 wire;它不再是 Embedded 的统一 transport,也不统一 GUI/TUI renderer、布局、键位、窗口、终端或 controller-local effect。 ## 3. 范围与非目标 本文范围包括: - Rich Client 的请求、响应、notification、错误、取消和恢复合同。 -- Embedded、Shared 和 WebSocket Host 的 transport 与生命周期边界。 +- Embedded direct adapter、Shared/WebSocket transport 与 Host 生命周期边界。 - Host 能力、transport limit、身份和执行域的协商。 - Desktop/Tauri、Web 和 TUI 的接入规则。 - App Server crate、Runtime owner 和产品装配之间的依赖方向。 @@ -99,17 +130,18 @@ App Server 的目标是提供一个可版本化、可生成 client、可跨 Embe - 强制 Headless CLI/CI、ACP、Peer Host 或公开 Agent SDK 使用 App Server。 - 统一 GUI 与 TUI 的状态机、renderer、布局、主题键或键位模型。 - 把 WebSocket transport 宣称为已具备多用户或公网安全性的公开 API。 -- 不允许临时兼容路径在没有明确决策、维护责任、版本规则和退出条件的情况下意外变成永久协议。若最终选择候选 B/C,应把保留的 Shared wire 明确定义为正式的部署专用协议,而不是继续称为临时兼容路径。 +- 不把缺少维护责任、版本规则和退出条件的临时兼容路径默认为永久协议;若保留 Shared wire,应将其明确定义为正式的部署专用协议。 ## 4. 术语 | 名词 | 含义 | 不等于 | | --- | --- | --- | -| App Server | 将版本化 Rich Client wire 映射到 Runtime API、Service 和 Product Domain owner 的协议适配层 | 业务 owner、通用 RPC 总线、必然独立的进程 | +| App Server | 将版本化 Rich Client wire 映射到 Runtime API、Service 和 Product Domain owner 的协议适配层;只用于需要该边界的 Host | 业务 owner、通用 RPC 总线、Embedded 的必经路径 | | App Server Client | 只依赖 wire contract、由 Host 提供 transport 的类型化客户端 | Runtime SDK、Server 构造器、UI 状态 owner | | Rich Client | 需要持续会话、交互事件和产品管理面的第一方 GUI/Web/TUI | Headless automation、ACP、公开 SDK | -| Host | 组装 App Server、选择 transport、注入能力并管理生命周期的产品入口 | 新业务层、普通用户必须管理的 Server 产品 | -| Embedded App Server | 与 Rich Client Host 位于同一 OS 进程的私有 App Server 实例 | Runtime 直连、网络 Server、共享后台进程 | +| Host | 组装 direct Runtime adapter 或 App Server、选择 transport、注入能力并管理生命周期的产品入口 | 新业务层、普通用户必须管理的 Server 产品 | +| Embedded direct Runtime | 与 Rich Client Host 同进程、由 `AgentRuntime` typed API 和 owner/provider facade 提供用例的部署方式 | 第二套 Runtime、App Server wire、跨进程后台服务 | +| Embedded App Server | 迁移前 TUI 使用的同进程私有 App Server 实例和 in-memory transport | Embedded 的目标默认路径、网络 Server、共享后台进程;Phase 5 完成后删除 | | Shared App Server | 由独立本机 Host 承载、允许多个已认证第一方 client 使用的 App Server 实例 | 公网 API、Agent SDK Host、每个 client 一个 Runtime | | Runtime owner | 持有 Session、Turn、Permission、Tool/MCP、Hook、事件和持久化事实的既有模块 | App Server handler 或 UI read model | | Host capability | 当前 Host 确实组装并允许调用的产品能力 | schema 中存在的方法全集 | @@ -128,16 +160,25 @@ flowchart LR GUI --> Host["Host adapter"] Web --> Host TUI --> Host - Host --> Client["App Server Client"] + Host --> Route{"Deployment route"} + Route -->|"Embedded direct · approved target"| Direct["Direct Runtime adapter"] + Route -->|"Web / candidate Shared · Phase 6"| Client["App Server Client"] Client --> Transport["Host-selected transport"] Transport --> Server["App Server"] - Server --> API["Runtime API / owner ports"] + Direct --> API["Runtime API / owner ports"] + Server --> API API --> Owners["Runtime · Services · Product Domains"] ``` -依赖和调用方向始终从入口流向 owner。Host 负责 transport 认证、连接作用域、capability/allowlist 和平台能力;App Server handler -负责 method 合同校验、handler 注册、DTO 转换和 Runtime/domain error 到 wire error 的映射。业务一致性、权限上限、持久化和权威状态 -仍由对应 owner 提交。 +依赖和调用方向始终从入口流向 owner。Embedded Host 负责 direct adapter 的 context +构造、能力选择和生命周期;需要连接边界的 Host 负责 transport 认证、连接作用域、 +capability/allowlist 和平台能力。App Server handler 负责 method 合同校验、handler +注册、DTO 转换和 Runtime/domain error 到 wire error 的映射。业务一致性、权限上限、 +持久化和权威状态仍由对应 owner 提交。 + +图中的 Web 分支是当前 loopback WebSocket App Server 路径;Shared 分支仅表示 Phase 6 +candidate,不是当前或已批准的必经链路。当前 Shared TUI 仍只使用 private Runtime IPC v17, +Web 也不经过 TUI backend composition。 ### 5.1 四层合同 @@ -148,37 +189,51 @@ flowchart LR | Host 合同 | transport、可用能力、限制、身份、作用域、生命周期和 controller-local provider | 复制业务规则或权威状态 | | Owner 合同 | Runtime/Service/Product Domain 的业务事实、校验和提交 | JSON-RPC、Tauri、WebSocket、Ratatui | -行为合同是 Embedded 与 Shared 等价的核心。仅复用 JSON 字段但在断连、超时、事件落后或权限上表现不同,不算统一 App Server 接口。 +行为合同是 Embedded direct-runtime 与 Shared wire 等价的核心;迁移前的 Embedded App Server +仅作为 direct-runtime 切换前的行为基线,不构成迁移后的第三条运行路径。其中,Phase 5 将引入的 +`TuiRuntimePort` 是 Shared IPC operation 集合在 TUI 侧的窄语义边界;管理 service/provider +不是 Shared Runtime wire 的组成部分。行为合同不要求三者共享 JSON;只要 Runtime port +在断连、超时、事件落后、权限、取消和 unknown outcome 上保持明确且可验证的语义, +即可共享同一 Runtime 用例合同。管理面按各自 service/provider 的可用性单独验证。 -## 6. Embedded deployment +## 6. Phase 5 Embedded interactive TUI target -Embedded Rich Client 的标准路径是: +本节只定义交互式 TUI 的 Phase 5 目标路径。它不描述 Desktop 或 Web 的迁移完成状态; +Desktop direct Runtime 是独立的已批准迁移步骤,尚未实施。 ```text -Rich Client +Embedded interactive TUI -> Host adapter - -> AppServerClient - -> private in-memory transport - -> private App Server instance + -> TuiBackend composition + -> TuiRuntimePort -> DirectRuntimeTuiRuntime -> AgentRuntime typed API + -> owner-owned service/provider interfaces (management only) -> Runtime API / owners ``` Embedded Host 必须: -1. 组装 Runtime 和 App Server,并将同一 owner 的端口注入 server。 -2. 创建方向固定的私有 in-memory transport pair。 -3. 通过正式 App Server Client 完成 initialize、请求、事件和 shutdown。 -4. 保证 server task/thread 在 Host 退出时被取消并回收。 -5. 使用与 Shared 相同的 schema、错误和行为测试。 - -Embedded 可以省略只对跨进程多客户端有意义的机制:endpoint discovery、进程 token、外部实例锁、多客户端 controller lease 和空闲后台退出。省略这些机制不能改变请求结果、事件顺序、取消结果或 capability 语义。 - -进程内 transport 仍可能执行 JSON-RPC 编解码。该成本是候选 A 必须测量的工程取舍;只有基准、资源预算和真实 consumer 证明可接受后, -才能把强制经过 App Server 作为批准约束。允许评估 transport buffer、生成代码和批量事件优化,但不能用未经验证的性能假设提前排除候选 B/C。 +1. 从产品组装结果取得唯一的 `AgentRuntime` 和必要的 owner/provider facade。 +2. 通过稳定 Rust Runtime API 构造 typed request,补齐 workspace、execution domain、 + permission 和 remote facts;不得从 UI 或全局环境猜测这些事实。 +3. 将 `AgentRuntime` 的事件/Permission receiver 映射为 `TuiRuntimePort` 可消费的 + semantic event, + 不创建第二个 Core `EventQueue` 订阅或第二份 read model 权威状态。 +4. 将 Runtime/domain error 映射为 Runtime port 或对应 owner service 的 TUI error,保留 `unsupported`、取消、 + `outcome_unknown` 等可观察语义。 +5. 在 Host 退出时取消并回收由 direct adapter 创建的订阅和任务,并使用与 Shared + 和 Web 路径相同的行为 fixture。 + +Embedded 可以省略只对跨进程多客户端有意义的机制:endpoint discovery、进程 token、 +外部实例锁、多客户端 controller lease、frame 编解码和空闲后台退出。省略这些机制 +不能改变请求结果、事件顺序、取消结果、权限边界或 capability 语义。 + +迁移窗口内可以用现有 Embedded App Server 与 direct adapter 做行为对照,但不保留可选的 +rollback adapter,也不得在 direct adapter 返回 unsupported 后静默回退。完成 direct-runtime +行为、性能和升级兼容验证后,Phase 5 必须删除旧路径。 ## 7. Shared deployment -Shared deployment 由一个本机 App Server Host 承载一个 Runtime owner,多个第一方 Rich Client 通过受控 Pipe、UDS 或等价私有 transport 连接: +当前 Shared deployment 由独立 Runtime Host 通过 private Runtime IPC v17 服务交互式 TUI,不运行 App Server。若后续采用 Shared App Server,则目标拓扑为一个本机 App Server Host 承载一个 Runtime owner,多个第一方 Rich Client 通过受控 Pipe、UDS 或等价私有 transport 连接: ```mermaid flowchart LR @@ -190,7 +245,7 @@ flowchart LR R --> D["Workspace and Session storage"] ``` -Shared Host 在基础 App Server 合同之外必须提供: +采用 App Server 的 Shared Host 在基础 App Server 合同之外必须提供: - 安全 endpoint discovery、实例身份和同用户认证材料。 - initialize-first 握手、协议版本和 client identity 校验。 @@ -206,24 +261,32 @@ Shared Host 在基础 App Server 合同之外必须提供: ## 8. Desktop GUI 与 Tauri -Desktop 的目标调用路径是: +Desktop 的调用路径按部署选择: ```text React UI -> frontend infrastructure / generated App Server client -> Desktop Host transport adapter - -> Embedded or Shared App Server + -> App Server(Web/Shared 或明确需要连接边界时) + +Embedded Desktop 的同进程产品请求可以走: + +React UI + -> frontend infrastructure + -> Desktop direct Runtime adapter + -> Runtime API / owner ports ``` -Tauri 继续拥有窗口、菜单、系统托盘、文件选择器、剪贴板、通知和进程级生命周期。Session、Turn、Workspace、Permission、Config、MCP、Skill、Hook 等产品后端能力必须迁入 App Server。 +Tauri 继续拥有窗口、菜单、系统托盘、文件选择器、剪贴板、通知和进程级生命周期。Session、Turn、Workspace、Permission、Config、MCP、Skill、Hook 等产品后端能力必须迁入既有 Runtime/owner;Embedded 时由 direct adapter 调用,需要连接边界时再由 App Server 做协议适配。 迁移规则: - UI 组件不得直接调用 Tauri API;调用进入前端 infrastructure/adapter。 -- Tauri command 若只承载产品后端用例,应由 App Server method 替代并逐步删除。 -- 必须由桌面原生 API 完成的 client-local capability 保留 Host-native 实现;需要与工作区或 Runtime 交互时拆成 App Server 数据流和本地 effect 两段。 -- Tauri event bridge 只能投递 App Server typed notification 或桌面专属事件,不能形成第二套 Runtime 事件语义。 -- Desktop Host 可在 Embedded 与 Shared 之间切换,但 UI 和生成 client 不包含 Runtime 直连分支。 +- Tauri command 若只承载产品后端用例,应由稳定 Runtime typed facade 或需要连接边界时的 App Server method 替代并逐步删除。 +- 必须由桌面原生 API 完成的 client-local capability 保留 Host-native 实现;需要与工作区或 Runtime 交互时拆成 direct/App Server 数据流和本地 effect 两段。 +- Tauri event bridge 只能投递 direct Runtime/App Server typed notification 或桌面专属事件,不能形成第二套 Runtime 事件语义。 +- Desktop Host 可在 direct Embedded 与 Shared/App Server 之间切换,但 UI 不包含部署分支; + route 选择留在 Host/infrastructure。 ## 9. Web 与远程 Host @@ -263,7 +326,7 @@ WebSocket 是 App Server 的一种 transport,不是另一套业务 API。Web H ## 11. 事件、恢复与取消 -权威 Runtime 事件通过同一 App Server connection 以 typed notification 发送。Host 不得让 client 绕过 App Server 直接订阅 Core `EventQueue`,也不得用有损 frontend projection 替代权威事件流。 +需要连接边界的 client 通过其 App Server connection 接收 typed Runtime notification;Embedded direct adapter 则通过 Runtime typed subscription 订阅同一权威 owner。App Server client 不得绕过连接直接订阅 Core `EventQueue`,direct adapter 也不得持有 Core queue receiver 或创建第二个事件 owner;两条路径都不得用有损 frontend projection 替代权威事件流。 每个事件流至少需要: @@ -307,7 +370,7 @@ App Server 是完整产品控制面,安全决策必须绑定到连接和业务 | 资源治理 | 连接、请求、frame、队列、并发、速率和任务生命周期有界 | | 远程边界 | 凭据、文件、进程和 Runtime 留在目标执行域;禁止本地 fallback | -Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传递明确的 Host/connection context,不能让 handler 从全局环境猜测调用主体。 +Embedded direct invocation 可以依赖同进程构造身份,但仍必须传递明确的 Host/request context,不能让 adapter 或 owner 从全局环境猜测调用主体。App Server connection 继续使用显式 connection context。 ## 14. Crate 与所有权边界 @@ -325,6 +388,13 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 - server wiring 可以依赖生产 handler 所需的明确 owner feature,但禁止选择 `bitfun-core/product-full`。 - 新 domain 只能增加真实 handler 所需的最窄 owner feature,并通过边界检查证明依赖方向。 - protocol DTO 不复制 Runtime 内部对象;只暴露 Rich Client 需要的稳定字段和 read model。 +- `app-server-protocol` 是 TypeScript wire schema 的唯一导出 owner;`app-server/ts` + 仅保留为兼容转发。owner 对象到 wire read model 的转换留在 server adapter,不能为了 + 生成类型把 Core、Agent Runtime 或 Service 实现依赖下沉到 protocol。 +- `app-server::schema` 只保留对 protocol 类型的兼容 re-export;正式 typed client 由 + `app-server-client` 单独拥有,server crate 不维护第二套隐藏 client。该 re-export 保证 + 已有平铺类型路径和 wire shape,不承诺保留 server-only 的 inherent/`From` 转换 helper; + 这些内部调用应迁到 server adapter 或稳定 contract 方法。 - transport 实现留在 Host/adapter 边界,generic role/transport helper 保持 schema-free。 - App Server 不持有第二份 Session、Permission、Config 或 capability 权威状态。 @@ -332,13 +402,28 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 迁移按行为闭环推进,不按 method 数量推进: -1. **锁定合同基础**:稳定 protocol/client crate、版本、错误、能力、限制和事件 envelope;增加 Embedded contract test。 -2. **完成 Embedded TUI**:所有交互式 TUI 产品请求经 `TuiBackend -> AppServerClient`;移除 Core、Runtime SDK、Service singleton 和 Runtime IPC 的 TUI-facing 依赖。 -3. **迁移 Desktop GUI**:按 Session/Turn/Permission、Workspace、Config/MCP/Extension 等垂直切片迁移;每片完成后删除重复 Tauri DTO/handler。 -4. **补齐 Shared 语义**:把 authentication、instance identity、controller/lease、framing、背压、断连取消、idle exit、event recovery 和 `outcome_unknown` 纳入 App Server Host/transport。 -5. **评审 Shared TUI 迁移**:候选 A 获批且 1.4 节门槛通过后,才用同一 client/schema 替换 Runtime IPC compatibility adapter;旧 wire 仅在 rollback 窗口结束并获得 owner 批准后删除。若选择 B/C,则记录 v17 的长期 owner、版本和删除条件。 -6. **收紧 Web Host**:由 Host 注入 allowlist、作用域和真实 limits;完成安全绑定前保持 loopback 单用户限制。 -7. **删除旁路**:移除 Rich Client 的 Core/Runtime 直连、重复事件投影和无生产消费方的旧 route。 + 1. **锁定 Runtime 行为合同**:按当前 Shared IPC v17 operation 集合冻结窄 + `TuiRuntimePort`,稳定 Runtime request/response/event 类型、错误、能力、取消和事件语义; + 为 direct Embedded 和 Shared 增加同一 Runtime 行为 fixture,迁移前可用旧 App Server 建立行为基线。 + 2. **拆分 TUI backend composition**:将当前单体 `TuiBackend` 拆为 `TuiRuntimePort` 与按 + domain 注入的 owner service/provider 接口;不定义总括性的 `TuiManagementPort`。管理 service + 能直接复用稳定 owner trait 的直接注入;只有需要 TUI DTO、权限/上下文或 capability 裁剪时 + 才增加薄 facade。 + 3. **迁移 Embedded TUI**:实现 `DirectRuntimeTuiRuntime`,将 Runtime 调用从 + `AppServerTuiBackend` 移出;再移除 in-memory transport、Embedded `BitfunAppServer`、 + server thread 和 TUI-facing App Server client 依赖。管理面使用 owner-owned service/provider, + 不把具体 `AppManagementService` 原样搬入 CLI/TUI。 +4. **迁移 Desktop GUI**:Embedded 时使用 direct Runtime adapter;Web/Shared 或需要连接治理 + 的场景保留 App Server。Tauri 继续承载平台能力与生命周期。 +5. **补齐 Shared 语义**:把 authentication、instance identity、controller/lease、framing、 + 背压、断连取消、idle exit、event recovery 和 `outcome_unknown` 纳入 App Server Host/transport。 +6. **评审 Shared TUI 迁移**:Shared App Server 只有通过 1.4 节门槛后才可替换 Runtime IPC + compatibility adapter;v17 是否删除由独立评审和验证证据决定。若选择 B/C, + 记录 v17 的长期 owner、版本和删除条件。 +7. **收紧 Web Host**:由 Host 注入 allowlist、作用域和真实 limits;完成安全绑定前保持 loopback + 单用户限制。 +8. **删除旁路**:移除 Rich Client 的重复 Runtime 事件投影、旧 Embedded App Server route 和 + 无生产消费方的兼容代码。 迁移期间不得在 App Server 返回 unsupported 后静默调用旧 Tauri/Core/IPC 路径。需要暂存旧路径时,必须由 Host 在启动时明确选择完整 adapter,且 UI 只看到一个 `TuiBackend` 或 frontend infrastructure 接口。 @@ -347,7 +432,11 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 ### 16.1 必需验证 - protocol serialization、版本上下界、未知字段和类型化错误合同测试。 -- 同一用例在 Embedded in-memory 与 Shared process transport 上的行为等价测试。 +- 同一用例在 Embedded direct-runtime、Shared process transport 和 WebSocket App Server(适用时) + 的行为等价测试;迁移前旧 Embedded App Server 仅用于建立基线,不作为持续测试路径。 +- `TuiRuntimePort` coverage:当前 Shared IPC v17 的每个 Runtime operation 都有对应的 + port 行为测试;管理 service/provider 则单独验证 capability、权限、unsupported 和 Remote + fail-closed,不以 Runtime port parity 代替管理面验证。 - Host capability/provider/allowlist 组合测试,以及真实 transport limit 测试。 - request identity、取消、断连、超时和 `outcome_unknown` 测试。 - 事件顺序、lag、invalidated、cursor/snapshot resync 和慢 client 测试。 @@ -356,26 +445,36 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 - Cargo 依赖闭包和 `product-full` 禁止规则。 - TypeScript/Rust client 生成结果与 schema 一致性检查。 -### 16.2 完成定义 - -若选择候选 A,只有同时满足以下条件,App Server Rich Client 架构才算完成: - -1. Desktop GUI、Web UI 和交互式 TUI 的产品后端请求与订阅均经过 App Server。 -2. Embedded 与 Shared 使用同一 client、method、DTO、错误和事件恢复合同,UI 不包含部署分支。 -3. Shared transport 达到现有 Runtime IPC 的鉴权、lease、取消、背压、限制、失效和生命周期等价。 -4. capability 和 limits 来自 Host 的真实装配与 transport,不再由通用 handler 无条件硬编码。 -5. Rich Client 不直接依赖 Core singleton、Runtime SDK、Tauri 业务 command 或私有 Runtime IPC。 -6. App Server handler 不持有业务权威状态,不复制 owner 校验和策略。 -7. Remote workspace 和多用户连接具有明确身份、作用域、授权和 fail-closed 行为。 -8. 重复 Tauri/Web/IPC DTO、旧 handler 和事件旁路已删除,或有明确的兼容期限与删除证据。 -9. 上述合同、行为、安全、依赖和跨入口测试全部通过。 - -## 17. Proposed constraints and open decisions - -### 17.1 Proposed target constraints - -- 若候选 A 获批,Rich Client Embedded 必须经过私有 in-process App Server。 -- Embedded 和 Shared 只有部署与连接治理差异,不产生第二套产品行为。 +### 16.2 TUI/App Server 解耦完成定义 + +只有交互式 TUI 同时满足以下条件,TUI/App Server 解耦才算完成;这一定义不涵盖 +Desktop 的独立 direct Runtime 迁移: + +1. Embedded TUI 的产品请求和订阅经过 `TuiAgentClient` 的 backend composition;Runtime + 行为经过 `TuiRuntimePort -> DirectRuntimeTuiRuntime`,管理能力经过对应 owner service/provider, + TUI view/reducer 不执行 backend I/O。 +2. direct adapter 只调用既有 Runtime API、owner-owned service/provider 和必要的薄 facade, + 不复制 Session、Permission、Config、capability 或事件权威状态,也不定义 `TuiManagementPort`。 +3. Embedded direct-runtime 与 Shared v17 的行为合同覆盖请求结果、事件顺序、 + 取消、权限、unsupported、断连和 unknown outcome。 +4. capability、作用域和 remote facts 来自真实 Host/Runtime 组装;Remote workspace 不存在 + controller-local fallback。 +5. Embedded 不创建 App Server client/server、in-memory transport 或额外 Runtime 进程; + direct adapter 的任务和订阅在 Host 退出时可回收。 +6. App Server 仍可被 Web/Shared Host 使用,且 handler 不持有业务权威状态或复制 owner 策略。 +7. 旧 Embedded App Server 路径已删除且不再作为 rollback adapter;70 方法单体 `TuiBackend` + 不再作为稳定接口边界。 +8. Shared transport 若要替换 v17,仍需单独满足鉴权、lease、取消、背压、限制、失效和生命周期 + 等价门槛。 +9. 上述合同、行为、安全、依赖、升级兼容和跨入口测试全部通过。 + +## 17. Constraints and open decisions + +### 17.1 Accepted target constraints + +- Embedded 默认直接调用同进程 Runtime typed API;只有需要连接/进程边界的 Rich Client 才经过 App Server。 +- Embedded direct-runtime 和 Shared 只有适配与连接治理差异,不产生第二套产品行为;旧 + Embedded App Server 仅是迁移前基线。 - App Server 只映射 owner,不成为 Session、Turn、Permission、Tool/MCP、Config 或事件 owner。 - Host capability 必须由真实装配、授权和 transport 共同决定。 - 事件丢失、断连和未知副作用结果必须显式可见,不能用轮询或盲重试掩盖。 @@ -391,4 +490,7 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 - Desktop client-local capability 的请求方向:App Server 反向 request、Host provider port,或显式两段式工作流。 - Web/Remote 的认证凭据来源、刷新、撤销和多租户资源配额。 -这些待决项会影响候选选择,不能被实现默认值或迁移进度替代。评审结论必须记录所选候选、拒绝其他候选的理由、门槛 owner、验证证据和回滚/删除条件;在此之前,当前 Embedded App Server 与 Shared v17 路径都保持有效。 +这些待决项会影响 Shared transport 的选择,不能被实现默认值或迁移进度替代。Shared 评审结论必须记录 +所选 transport、拒绝其他方案的理由、门槛 owner、验证证据和回滚/删除条件。Embedded direct-runtime +迁移完成后,旧 Embedded App Server 必须删除且不保留回滚路径;WebSocket App Server 与 Shared v17 +按各自部署合同继续有效。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index a4492d158a..2c793f3086 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -213,7 +213,7 @@ CLI 只消费 typed summary 与 typed action: | 变更范围 | 最小验证 | | --- | --- | | TUI state/input/render | focused reducer/input/render test + `cargo test -p bitfun-cli` | -| Agent Runtime SDK/port | `cargo test -p bitfun-agent-runtime` + owner focused test | +| Agent Runtime SDK/port | `cargo test -p bitfun-agent-runtime --no-default-features --features agent-runtime --lib` + owner focused test | | Shared IPC | protocol round-trip、controller/idle、timeout/outcome-unknown、disconnect cancel | | Core turn/tool | 权限 allow/ask/deny、取消、事件、上下文、持久化恢复 | | `exec` output | stdout/stderr、单一最终状态、session conflict、Ctrl+C、Patch | diff --git a/docs/architecture/extensions/plugin-runtime-design.md b/docs/architecture/extensions/plugin-runtime-design.md index bcaada2a78..968f4d8b67 100644 --- a/docs/architecture/extensions/plugin-runtime-design.md +++ b/docs/architecture/extensions/plugin-runtime-design.md @@ -281,11 +281,11 @@ plugin、Hook、完整 Client 或 TUI 插件入口。与其独立的 standalone 当前 Rust 边界调整至少运行: -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_contracts` -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_diagnostics_contracts` +- `cargo test --locked -p bitfun-runtime-ports --no-default-features --features plugin-runtime --test plugin_runtime_contracts plugin_runtime_contracts` +- `cargo test --locked -p bitfun-runtime-ports --no-default-features --features plugin-runtime --test plugin_runtime_contracts plugin_runtime_diagnostics_contracts` - `cargo test -p bitfun-plugin-runtime-client` - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` -- `cargo test -p bitfun-core plugin_runtime::tests --lib` +- `cargo test -p bitfun-core --no-default-features --features plugin-runtime --lib plugin_runtime::tests` - `node scripts/check-core-boundaries.mjs` 目标 Plugin Host 还必须使用固定版本真实 fixture 验证: diff --git a/docs/architecture/i18n.md b/docs/architecture/i18n.md index 4d43ae96b4..3783df3b8a 100644 --- a/docs/architecture/i18n.md +++ b/docs/architecture/i18n.md @@ -52,6 +52,14 @@ Do not edit generated files manually. Shared contract does not mean shared bundle. Smaller product shapes must not pay for Web UI resource size. +The backend follows the same split at compile time. `LocaleId`, locale aliases, +fallback facts, metadata, and model-facing language copy are feature-free Core +contracts. Fluent parsing, backend bundles, `I18nService`, and its global mutable +state are enabled only by the Core `i18n-runtime` owner feature. A host that +initializes or updates the backend service must select that feature explicitly; +`product-full` and App Server do so, while contract-only Core consumers do not +compile Fluent. + ## Lookup And Override Order Resource intent has three layers: diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index f2dfb2b35d..cfbbdbed72 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -12,6 +12,72 @@ A's workspace list, sessions, assistants, chat, and tools behave like using BitFun on B's machine. The authority is **B's live local BitFun state** via HostInvoke / DeviceEvent fan-out — not a merged cloud session history. +## Attachment vs rendered surface + +Two concepts, deliberately independent: + +| | Attachment | Rendered surface | +|---|---|---| +| What it is | A live control link to a peer | The one device this window draws | +| How many | Any number, concurrently | Exactly one | +| Ends when | Explicit disconnect, peer offline, logout | Replaced by the next switch | +| Effect on the peer's agent | Keeps it running and fanning out | None | + +This split is what makes several devices usable at once: dispatch a turn on B, +switch the UI back to A, dispatch another turn on A, and both keep running. +The frontend entry points are `switchToDevice` / `switchToLocal` / +`disconnectDevice` on `PeerDeviceContext`; the sidebar `DeviceSurfaceSwitcher` +lists this machine plus every online peer. + +Two rules follow, and both are load-bearing: + +- **A surface switch never mutates the device being left.** Everything in + `resetProductSurface()` is frontend-only. Sending `terminal_shutdown_all` or + `lsp_close_workspace` during a switch lands on the *previous* transport and + kills work an agent there still depends on. +- **Product events are routed by their source device.** The controller re-emits + peer DeviceEvents under their original event name, so with peers attached in + the background one bus carries several agent streams. The desktop controller + tags each re-emitted payload with `__bitfunSourceDeviceId` + (`remote_connect_api::PEER_EVENT_SOURCE_KEY`; non-object payloads are wrapped + under `__bitfunSourcePayload`), and `deviceSurfaceRouting.ts` — applied inside + `TauriTransportAdapter.listen` — delivers a surface-scoped event only when its + producing device is the rendered one. Untagged events are local by definition. + Control-plane events (`account://…`, window chrome, updater) are never scoped + and always pass. + +### Surface identity and activation + +The rendered device is a first-class `DeviceSurfaceId` (`local` or a peer +device id), not an implicit property of one mutable global transport. Cache, +request, capability, workspace, session-state-machine, processing-status, +pending-message, and composer-draft identity includes that surface. FlowChat +and workspace state are stored in per-surface containers: switching selects a +container immediately, then reconciles it with its host; it does not erase the +container belonging to the device being left. + +Every surface activation creates a monotonic epoch and `AbortSignal`. +Product invokes capture that epoch, including through `ApiClient`; a response +or retry that outlives it raises `SurfaceChangedError` and is abandoned as +control flow. Controller-plane commands are exempt because their authority +remains the controller regardless of the rendered surface. Transport/event +routing and container selection commit synchronously in `activateSurface` so +no observer can see B's state while requests still target A. + +`PeerDeviceSurfaceController` serializes activation outside React. Rapid +requests coalesce to the last target, a committed-but-superseded hydrate is +invalidated before the next target proceeds, and a real activation failure +rolls back to the previously rendered reachable surface. Separately, +`PeerConnectionManager` owns each attachment's +`connecting`/`ready`/`degraded`/`lost` lifecycle, keepalive and bounded backoff; +React only subscribes to snapshots. Attachment disposal is the only operation +that discards a peer's cached surface state. + +Because the local surface can now miss its own events while another device is +rendered, snapshot reconciliation is no longer Peer-only: after this window's +first surface switch, `isSurfaceReconcileEnabled()` keeps the repair loop +running for whichever surface is rendered, local included. + ## Cloud account sync vs Peer Remote | Concern | Account cloud sync | Peer Device Mode | @@ -38,8 +104,12 @@ FS) and must not be mixed with Peer Device Mode. ## Boundaries - Not SSH `WorkspaceKind.Remote` (local session mirror + remote FS). -- Enter via Account Login → Online Devices → click peer. -- Exit via sidebar Peer Remote status row `Disconnect` (device name + disconnect). +- Switch via the sidebar device switcher, or Account Login → Online Devices → + click a device. Both list this machine, so returning to it is a switch like + any other. +- Selecting this machine only changes what is rendered; peers stay attached and + keep working. `Disconnect` in the switcher is the separate, explicit action + that ends a peer's control link and cancels the work it runs for us. - Local-only commands (window chrome, updater, account login/logout, peer control plane) never execute on the peer on behalf of a controller. - Unsupported or denied commands fail loudly; they must not fall back to the @@ -65,6 +135,9 @@ FS) and must not be mixed with Peer Device Mode. only when the initial `peer_mode_ping` advertises `idempotent_dialog_submit`, so mixed-version peers remain single-shot. Other mutations remain single-shot because a timed-out outcome is unknown. + Identity-based Session rollback is sent only when `peer_mode_ping` advertises + `targeted_session_rollback`; older peers fail explicitly and never fall back + to controller-local files, history, or the removed numeric rollback command. The desktop `account_device_rpc` command enforces the requested deadline around the native HTTP future; the controller's Promise deadline is not merely a UI timer. Failed session-list loads leave the spinner and expose an @@ -168,6 +241,9 @@ and may represent a different operating system. `src/apps/cli/src/account_sync.rs`. - Frontend mode + transport: `src/web-ui/src/infrastructure/peer-device/`, `adapters/peer-device-adapter.ts` +- Surface routing / switcher: `deviceSurfaceRouting.ts`, + `deviceSurfaceReconcile.ts`, `deviceActivity.ts`, + `DeviceSurfaceSwitcher.tsx`, `useAccountDeviceRoster.ts` - Peer directory picker: `pickWorkspaceDirectory.ts`, `PeerDirectoryBrowser.tsx`, `PeerDirectoryPickerHost.tsx` diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 9c171546ba..769e414cbb 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -20,10 +20,10 @@ Headless CLI 与各产品入口的统一心智见 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md);多个 GUI/TUI/Remote/CLI/SDK 实例共存时的 Agent Runtime 部署、 状态共享、隔离、容量与 Plugin Host 关系见 -[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);Desktop GUI、Web UI 和交互式 TUI 的统一产品后端协议、 -Embedded/Shared App Server 边界及迁移约束见 -[`app-server-architecture.md`](app-server-architecture.md)。该专题当前是待评审的目标提案;在决策门槛通过前,当前调用路径和稳定 -owner 边界仍以本文及已接线代码为准。其他已批准的详细设计与本文件冲突时,以本文件为准。 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);Desktop GUI、Web UI 和交互式 TUI 的产品后端边界、 +Embedded direct-runtime、Shared App Server 及迁移约束见 +[`app-server-architecture.md`](app-server-architecture.md)。Embedded direct-runtime 已确定为下一步实现方向;Shared App Server +仍是待评审提案。在迁移或决策门槛通过前,当前调用路径和稳定 owner 边界仍以本文及已接线代码为准。其他已批准的详细设计与本文件冲突时,以本文件为准。 Cargo feature、第三方依赖 owner、测试目标和本地/CI 验证分工见 [`rust-build-dependency-boundaries.md`](rust-build-dependency-boundaries.md)。该文档补充本架构的构建视图,不改变本文定义的运行时 owner 和分层依赖方向。 @@ -308,9 +308,9 @@ flowchart LR ### 2.4 Physical View · Level 0 -Physical View 展示当前可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime; -Embedded 交互式 TUI 已在同一 CLI 进程内通过私有 App Server 使用 Runtime,交互式 TUI 也可以显式连接当前 Shared Runtime IPC。 -Desktop GUI 的 App Server 迁移尚未完成。当前 loopback Web Server 已承载 Embedded Runtime 和 WebSocket App Server;Relay Server +Physical View 展示当前生产环境中可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime; +Embedded 交互式 TUI 当前仍在同一 CLI 进程内通过私有 App Server 使用 Runtime;交互式 TUI 也可以显式连接当前 Shared Runtime IPC。 +Desktop GUI 当前仍使用 Tauri adapter;独立 direct Runtime 迁移尚未实施。当前 loopback Web Server 已承载 Embedded Runtime 和 WebSocket App Server;Relay Server 不承载 Agent Runtime。 ```mermaid @@ -374,8 +374,8 @@ flowchart LR | Deployment unit | Main contents | |---|---| -| Desktop App | Web UI、Tauri Host、embedded Agent Runtime;Rich Client App Server 迁移尚未完成 | -| CLI App | 交互式 TUI 通过 private in-process App Server 使用 Embedded Runtime;Headless、Peer 保留独立 adapter;可显式使用 Shared TUI | +| Desktop App | Web UI、Tauri Host、embedded Agent Runtime;当前 Desktop 产品请求使用现有 Tauri adapter | +| CLI App | 交互式 TUI 当前通过 in-process App Server 使用 Embedded Runtime;Headless、Peer 保留独立 adapter;可显式使用 Shared TUI | | Shared Runtime | 私有本机 IPC;当前只有交互式 TUI consumer;是否迁入 Shared App Server transport 仍待评审与等价证据 | | ACP | Embedded Agent Runtime、ACP 协议生命周期 | | SDK Host | 私有跨进程 adapter;公开 SDK 产品尚未交付 | @@ -423,11 +423,19 @@ flowchart TB ## 3. 接口边界 BitFun 只保留四个稳定业务接口边界;工具、事件和权限作为归属子接口被复用,不在插件层重复定义。App Server -是 Agent Runtime API 和其他 owner 接口面向当前 Web/Embedded TUI 以及候选 Rich Client 目标的版本化 wire adapter,不新增第五个 -业务 owner 或能力分类。是否扩大到全部 Rich Client 由 4.2 节所述评审决定。本文使用 +是 Agent Runtime API 和其他 owner 接口面向当前 Web/Embedded TUI,以及未来确实需要连接边界的 Rich Client 的版本化 wire adapter,不新增第五个 +业务 owner 或能力分类。Embedded TUI 的目标改为 direct Runtime adapter;Shared 是否使用 App Server 由 4.3 节所述评审决定。本文使用 “接口”描述可被调用或依赖的能力面;只有描述跨进程消息封装、结构化 schema、序列化对象或强兼容约束时才使用 “契约”;只读状态视图表示从权威状态派生出的查询结果。 +Phase 5 将为 Embedded/Shared TUI 冻结窄的 `TuiRuntimePort`,其范围按 Shared IPC v17 +实际承载的 Session、Turn、Permission/UserInput、Workspace、lineage、usage/settlement、 +model/mode 更新、agent mode catalog 和事件订阅确定。Model/Skill/Subagent/MCP、Account、 +Settings Sync、Worktree、External Source 和 Hook 等管理面不组成一个 `TuiManagementPort`, +也不因为存在 TUI 用例就进入 Shared Runtime wire;它们由 TUI backend composition 按 domain +直接依赖 owner-owned 的稳定 service/provider trait。只有需要 TUI DTO、权限/上下文适配、 +内部类型隔离或 capability 裁剪时,才增加薄 facade。 + | 接口边界 | 谁使用 | 提供 | 不包含 | |---|---|---|---| | Agent Runtime API | App Server、Headless CLI、ACP、Server、Remote、SDK 等 adapter | Query、Session、Tool/MCP、Permission、Hook、Event、Usage | UI、Rich Client wire、协议和具体服务实现 | @@ -476,21 +484,29 @@ handler,不构成生产消费完整流程。 client 或未来 CLI/HarmonyOS 计划,不能证明同名 Rust transport adapter 已接入;未接入实现应删除,待端到端 调用链确定后再按宿主边界实现。 +多个已接入载体重复使用的协议无关机械能力是例外:有界 JSON 编码与消息大小校验、消息级背压和 JSON-RPC request +correlation 可以由 `adapters/transport` 统一持有。仅 private Runtime IPC 使用的 length-prefix framing 仍留在该协议 owner, +待第二个当前消费者共享相同语义后再评估抽取。该基础层只接受 bytes/message 与调用方提供的 +限额,不得知道 App Server、private Runtime IPC、SDK Host 的 method/DTO,也不得决定认证、controller/lease、重连、 +自动重试或业务生命周期。stdio、Named Pipe/UDS、WebSocket 仍由各 Host 选择具体 framing 和安全策略;复用机械层 +不能被解释成这些入口共享同一 wire 或同一 Runtime 进程。 + ### 3.2 宿主通信契约与 Tauri 薄适配 前后端契约按能力语义归属,不按 Tauri command 名称归属。稳定的请求、响应、状态事实和类型化错误放在对应 `contracts/*`、Agent Runtime API 或能力归属模块。当前 Desktop GUI 仍使用 Tauri adapter,Web UI 使用 loopback WebSocket -App Server,Embedded TUI 使用 in-process App Server,Shared TUI 通过 `TuiBackend` 映射 private Runtime IPC v17。待评审目标是让 -Desktop GUI、Web UI 和交互式 TUI 复用同一 Rich Client App Server 行为与 wire contract;Tauri 和各 Rich Client Host 负责 -transport、平台能力及生命周期。ACP、Headless CLI、Peer Host 与公开 SDK 继续由各自 adapter 映射到稳定 owner 接口,不因该目标 -复用 App Server wire。该规则降低框架耦合,但不要求把 controller-local Desktop DTO 搬进共享 crate。 +App Server,Embedded TUI 当前使用 in-process App Server,Shared TUI 通过 `TuiBackend` 映射 private Runtime IPC v17。目标是让 +Embedded GUI/TUI 通过 Host-owned direct adapter 调用 Runtime typed API,需要连接边界的 Web/Shared Rich Client 使用 App Server; +Tauri 和各 Rich Client Host 负责 adapter、transport、平台能力及生命周期。ACP、Headless CLI、Peer Host 与公开 SDK 继续由各自 +adapter 映射到稳定 owner 接口,不因该目标复用 App Server wire。该规则降低框架耦合,但不要求把 controller-local Desktop DTO +搬进共享 crate。 | 层 | 允许 | 禁止 | |---|---|---| | 能力归属模块 / Agent Runtime API | 字段明确的请求和响应、状态事实、权限/取消规则、与框架无关的用例方法 | `tauri::State`、`AppHandle`、窗口/菜单对象、command 宏、HTTP/WebSocket/ACP/SDK Host 消息结构 | -| Desktop Tauri / proposed App Server Host adapter | 当前组装 Tauri adapter;目标组装 transport、注入真实 capability 与平台 provider、管理窗口和桌面生命周期、投递 App Server typed notification 或桌面专属事件 | 复制业务校验、持有第二份权威状态、在目标迁移完成后为同一能力保留第二条 Runtime 旁路、把 Tauri 类型传入下层 | +| Desktop Tauri / product Host adapter | 当前组装 Tauri adapter;目标按部署组装 direct Runtime adapter 或 App Server transport、注入真实 capability 与平台 provider、管理窗口和桌面生命周期、投递 typed Runtime/App Server notification 或桌面专属事件 | 复制业务校验、持有第二份权威状态、在目标迁移完成后为同一能力保留第二条 Runtime 旁路、把 Tauri 类型传入下层 | | Server / Remote adapter | 路由鉴权、协议消息、连接生命周期、流量控制与取消转换 | 为同一能力另建业务含义不同的 DTO 或 handler | -| GUI / Web / TUI frontend | 当前依赖各自 infrastructure 或 `TuiBackend`;目标依赖生成的 App Server client、稳定读模型和 Host-local capability adapter;各自保留渲染状态 | 在 UI component/view 中直接依赖 Runtime/Core/Service、公开 Python/TypeScript SDK、Tauri 业务 command 或私有 Shared IPC | +| GUI / Web / TUI frontend | 当前依赖各自 infrastructure 或 `TuiBackend`;目标依赖 frontend/app infrastructure,由其组合 `TuiRuntimePort`、owner service/provider adapter 和需要时的 App Server client;各自保留渲染状态 | 在 UI component/view 中直接依赖 Runtime/Core/Service、公开 Python/TypeScript SDK、Tauri 业务 command 或私有 Shared IPC | 本文其他章节和历史设计中出现的“Runtime SDK”,如果指 `agent-runtime::sdk`,统一称为 **Rust Runtime SDK(当前 preview)**;它是共享 **Agent Runtime API** 的当前 Rust 入口。只有 @@ -538,8 +554,9 @@ Desktop command 使用的序列化对象继续留在 `src/apps/desktop`;即使 ## 4. 运行协作细节 -本节在 Process View Level 0 之下展开产品入口、插件调用和平台能力。Current 图只描述当前已接线请求路径;Proposed target 图 -描述待评审方向。两者都只描述组件协作,不构成新的 4+1 视图。 +本节在 Process View Level 0 之下展开产品入口、插件调用和平台能力。Current 图只描述当前已接线请求路径;Approved Embedded target +和 Optional Shared proposal 分别描述已批准但未交付的 Embedded direct-runtime,以及仍待评审的 Shared App Server。三者都只描述组件协作, +不构成新的 4+1 视图。 ### 4.1 Current product entry paths @@ -548,7 +565,7 @@ flowchart LR Desktop["Desktop GUI"] --> Tauri["Desktop / Tauri adapter"] Web["Web UI"] --> WebHost["loopback WebSocket App Server"] TUI["Interactive TUI"] --> Backend["TuiBackend"] - Backend -->|"Embedded"| EmbeddedAS["in-process App Server"] + Backend -->|"Embedded current"| EmbeddedAS["in-process App Server"] Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] Other["Headless CLI · ACP · Server · Remote"] --> Adapter["独立入口适配器"] SDK["Rust Runtime SDK / SDK Host preview"] --> SDKAdapter["独立 SDK adapter"] @@ -561,43 +578,52 @@ flowchart LR API --> Runtime["共享 Runtime"] ``` -当前 Embedded TUI 核心路径经过 App Server,Shared TUI 则由 `TuiBackend` compatibility adapter 映射到 private Runtime IPC v17。 -Desktop GUI 尚未完成 App Server 迁移;当前 loopback Web Host 已通过 WebSocket 承载 App Server。Headless CLI/CI、ACP、Peer Host -和 SDK Host 保留独立 adapter。所有路径最终消费同一 Runtime API 或 owner port,部署选择不能进入业务 owner。 +当前 Embedded TUI 核心路径经过 in-process App Server,Shared TUI 通过 private Runtime IPC v17;Web UI 通过 loopback WebSocket App Server, +Desktop GUI 通过 Tauri adapter。Headless CLI/CI、ACP、Peer Host 和 SDK Host 保留独立 adapter。所有路径最终消费同一 Runtime API 或 owner +port,部署选择不能进入业务 owner。目标路径不在本图中展开。 Server bootstrap 和产品组装只创建对象并注入依赖,不是客户端请求的第二条旁路: ```mermaid flowchart LR - Assembly["产品组装"] -. "constructs" .-> Host["Host-owned App Server + transport"] + Assembly["产品组装"] -. "constructs" .-> Host["Host-owned adapter + transport when needed"] Assembly -. "constructs" .-> Runtime["Runtime / owner implementations"] Runtime -. "injects owner ports" .-> Host ``` 图中的虚线全部表示启动期 composition;业务请求仍只沿前一张 Current 图中的实线进入 Runtime API 或 owner port。 -### 4.2 Proposed target product entry paths +### 4.2 Approved Embedded target ```mermaid flowchart LR - Rich["Desktop GUI · Web UI · Interactive TUI"] --> Host["Rich Client Host"] - Host --> Client["App Server client"] - Client --> Transport["Host-selected transport"] - Transport --> AppServer["App Server"] - Other["Headless CLI · ACP · Peer Host"] --> Adapter["独立入口适配器"] - SDK["Public Agent SDK"] --> SDKHost["SDK Host"] - AppServer --> API["Runtime API / owner ports"] - Adapter --> API - SDKHost --> API + TUI["Embedded interactive TUI"] --> Composition["TuiBackend composition"] + Composition --> RuntimePort["TuiRuntimePort"] + RuntimePort --> DirectRuntime["Direct Runtime adapter"] + DirectRuntime --> API["Runtime API / owner ports"] + Composition --> Management["owner service/provider adapters"] API --> Runtime["共享 Runtime"] ``` -提案目标是让 Desktop GUI、Web UI 和交互式 TUI 复用 App Server 行为与 wire contract,并让 Embedded/Shared 只在 Host 与 -transport 层不同。是否用 Shared App Server 替换 v17,仍取决于鉴权、实例身份、controller/lease、事件恢复、取消、限制、性能和 -回滚门槛;目标图不表示这些能力已经交付。各入口仍各自拥有 renderer、平台能力和生命周期。Headless CLI/CI、ACP、Peer Host 和 -公开 SDK 不共享 App Server wire。 +这是已批准但尚未交付的 Embedded direct-runtime 目标,属于 Phase 5。迁移完成前,Embedded TUI 继续使用 Current 图中的 in-process +App Server;管理能力按 domain 由 composition 注入 owner service/provider,不组成 `TuiManagementPort`。 + +### 4.3 Optional Shared App Server proposal + +```mermaid +flowchart LR + C1["Shared Rich Client 1"] --> Transport["candidate private Pipe / UDS"] + C2["Shared Rich Client 2"] --> Transport + Transport --> Host["Shared App Server Host"] + Host --> Runtime["one Agent Runtime owner"] + Runtime --> Storage["Workspace / Session storage"] +``` + +这是 Phase 6 的待评审提案,不是当前 Shared TUI 的必经链路,也不改变 Current 图中的 private Runtime IPC v17。只有完成鉴权、实例身份、 +controller/lease、事件恢复、取消、限制、性能和回滚门槛后,才可评审是否替换 v17;评审也可以决定长期保留 v17。Web UI 不经过 TUI +composition,而是继续通过自己的 loopback WebSocket App Server 入口。 -### 4.3 插件调用 +### 4.4 插件调用 ```mermaid flowchart LR @@ -614,7 +640,7 @@ flowchart LR Adapter["生态 adapter"] --> Provider["能力 Provider"] --> Owner["能力归属模块"] ``` -### 4.4 平台能力 +### 4.5 平台能力 ```mermaid flowchart LR @@ -625,7 +651,7 @@ flowchart LR 关键规则: -- Current 产品请求遵循 4.1 节;4.2 节的 Rich Client App Server 统一路径只有在相应 Host 完成迁移和验证后才成为当前路径。 +- Current 产品请求遵循 4.1 节;4.2 节的 Embedded direct 路径只有在相应 Host 完成迁移和验证后才成为当前路径,Shared App Server 分支还需独立评审。 其他产品入口先经过自己的 adapter,再消费 Agent Runtime API、owner port 和只读视图;公开 SDK 只多一层 SDK Host 跨进程适配。 Agent Runtime API 是一组小而明确的用例接口,不是必须实例化的总入口;adapter 可以调用对应归属模块的少量接口, 但不能访问内部状态、绕过既有编排或复制业务规则。任何入口都不直接调用 Plugin Host。 @@ -649,7 +675,7 @@ flowchart LR 可以选择下层提供方,但不能依赖 app crate;需要同时被独立应用和嵌入式模式复用的实现必须下沉到可复用 owner, 再由各 app 和 assembly 组合。 -### 4.5 名词与定义归属 +### 4.6 名词与定义归属 全仓人工维护文档、AGENTS、README 和代码注释遵守以下规则: @@ -820,6 +846,7 @@ flowchart LR | Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | | CLI / TUI | 使用显式 Core owner closure:`agent-runtime` 基线、实际 service owner(包括 Remote Connect、DeepResearch、LSP、external/plugin source 与 SSH)以及九组 `tools-*`;`/extensions` 只提供状态、启停和刷新,`/hooks`、`/tools`、`/agent` 和 `/mcp` 处理各自能力 | `agent-runtime` 不再隐式携带完整 MCP/Remote/Browser/Web/Git/LSP/模型目录闭包;非交互不等待权限输入,生态解析仍在适配器,远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts、`agent-runtime` 基线、所需 service owner 与九组 `tools-*`,但不选择 CLI 的 plugin runtime 和 Remote Connect owner | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理;未选择的能力不得借 Cargo feature union 偶然出现 | +| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts 和与当前本机协议能力一致的显式 Core owner closure;TLS provider 由 Host 进程入口安装 | 当前协议不暴露远程 workspace/SSH 执行,因此不选择 Remote Connect、SSH 或 Function Agent owner;未来远程 SDK 必须复用 Server/Remote 的认证和执行域,不能回退到本机执行 | | Peer / Server | Peer Host 执行真实工作区操作;通用 HTTP Server 未绑定可信 workspace owner 时明确返回不支持 | 控制端不替远端发现或执行;loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/docs/architecture/remote-workspace-transport.md b/docs/architecture/remote-workspace-transport.md index bd2bb5cb2f..669a78269b 100644 --- a/docs/architecture/remote-workspace-transport.md +++ b/docs/architecture/remote-workspace-transport.md @@ -138,6 +138,26 @@ omit an entry or follow a link outside the selected tree. Host bind mounts are not path-translated. A host path is visible only at the path mounted inside the container. +Workspace-facing file requests are scoped by the remote connection id together +with the POSIX workspace path whenever that id is known. A path alone is only a +legacy compatibility fallback: the same path may exist on multiple SSH hosts, +so new browse and search callers must carry the session or workspace connection +id through the platform adapter. One product surface must use the same scope for +directory browsing and filename search instead of consulting mutable global +workspace state between requests. +An explicit connection id is exact target identity, not a best-effort hint. If +that connection is unavailable or does not own the requested path, the request +fails without reading the controller filesystem or another registered host. + +Recursive remote filename searches publish cancellable progress as matches are +discovered. Product surfaces render those partial results before traversal +completes and preserve a distinct error state; a transport or routing failure +must not be presented as an empty search result. +The transport adapter advertises whether command-scoped search events reach the +current surface. Peer Device controllers without a negotiated search-event +contract use the response-based command on the peer host; they never fall back +to searching the controller filesystem. + ## Authentication and secrets Supported target methods are password, private key, private key plus OpenSSH diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index 8815f04bd4..6d30313b5a 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -47,17 +47,29 @@ Cargo 会统一同一 package 在依赖图中的 feature;workspace dependency ### 3.2 产品入口显式选择 Core 能力 -`src/apps/*`、`src/crates/interfaces/*` 和 installer app 直接依赖 `bitfun-core` 时必须同时: - -1. 设置 `default-features = false`; -2. 声明非空 `features` 列表。 +`src/apps/*`、`src/crates/interfaces/*` 和 installer app 直接依赖 `bitfun-core` 时必须声明非空 +`features` 列表。Core 的 `default` 由 Core 自身和边界检查保证为空,因此仓内 consumer 不重复声明 +`default-features = false`;能力边界仍由 consumer 的显式 feature 集合决定。 入口应选择真实需要的 owner feature;`product-full` 只能描述确实需要完整产品装配的兼容入口,不能作为尚未完成 feature/owner 分解时的占位解法。缩小某个产品的 capability 集合时必须从实际 construction/command path 反推,并保留行为等价或明确 unsupported-state 测试。 +Core library 的默认 feature 集合为空;完整产品必须显式选择 `product-full`。若 interface crate 以多个 +公开角色复用一条 optional Core dependency,则 dependency 声明本身保持无 feature,每个角色 feature +分别激活 Core 并选择自己的非空 owner 闭包;兼容默认值只能组合这些已评审角色,不能重新引入 +`product-full`。每个角色必须独立编译,产品消费者还要显式关闭该 interface crate 的默认 feature 并选择 +真实使用的角色,避免 workspace feature union 掩盖边界缺口。 + Core 的 `agent-runtime` 只承载 Agent 生命周期基线和明确的基线工具,不得再次把 MCP、Remote Connect、模型目录、Browser/Web、Git/LSP 或产品工具组藏成 capability union。具体 service 由同名 owner feature 选择,内置工具由 `tools-*` 选择;`product-full` 显式相加全部 owner,CLI/ACP 等窄入口则按真实命令与构造路径列出自己的闭包。 +执行层的 `bitfun-agent-runtime` 自身也保持空默认:完整生命周期由 `agent-runtime` 选择,DeepResearch 纯编号由 `deep-research` 选择,原生 Hook 配置解析与进程执行分别由 `native-hook-settings`、`native-hook-runtime` 选择。叶能力仍留在原 owner crate 内,不为依赖收敛新建 DTO/runtime crate;完整产品必须显式恢复真实 owner,不能依赖 workspace feature union 偶然补齐。 + Owner feature 不等于“无前置依赖”。当实现确实调用较低层基线时,依赖必须按 `owner → baseline` 显式组合,禁止反向把 owner 藏回基线:例如 Core MCP 工具桥和 Remote Connect 依赖 Agent 生命周期,Workspace Search 依赖本地 Workspace Runtime。每个新增或调整后的 owner 闭包都必须单独 `cargo check`,避免被 Desktop/CLI 的 feature union 偶然补齐。 +只为已经启用的 optional dependency 增加子能力时,使用 Cargo 的弱依赖转发 +`dependency?/feature`,并把 modifier 与 runtime owner 分开命名和看护。modifier 单独启用不得激活 +runtime dependency;真实产品入口必须同时显式选择 owner 与 modifier。不要为了复用一个子 feature +把完整 adapter、service 或 tool runtime 拉回窄闭包。 + Function Agent 的 Git/AI 适配由 `function-agents` 选择,MiniApp 的 domain/runtime/market 闭包由 `tools-miniapp` 选择;不得再通过一个通用 `product-domains` Core feature 把两者、 Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品装配计划若声明了当前 @@ -72,19 +84,42 @@ Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品 ### 3.3 Workspace dependency 只提供共同底座 - workspace 声明负责版本和真正跨产品共享的最小 feature; +- 第三方依赖的默认 feature 若不是每个 consumer 的稳定契约,在 workspace 声明统一关闭;成员继承该策略, + 只增加自身实际使用的 feature,不在各 manifest 重复 `default-features = false`; +- 仓内 crate 的空默认值由被依赖 crate 拥有并由边界检查锁定,consumer 不重复关闭;只有 ACP 这类有意保留 + 非空兼容默认的 crate,窄 consumer 才必须显式关闭默认值并选择角色; +- 被 Docker 等独立构建上下文单独复制的 manifest 无法继承 workspace 根,继续显式声明版本与默认策略; - runtime、HTTP、TLS、crypto、codec 等产品特定 feature 留给实际 app/service/adapter owner; - `full` feature 只有在所有真实消费者都需要且更窄集合不能稳定维护时才允许; - target-specific dependency 放在最接近平台实现的 owner,不因单一平台需求污染跨平台 crate; - 修改共享 dependency feature 视为构建影响变更,必须检查真实产品组合的 feature graph。 -### 3.4 Reqwest TLS 后端由客户端 owner 选择 +### 3.4 Reqwest 能力由客户端 owner 选择 -- workspace 级 `reqwest` 只统一版本以及跨产品共享的 HTTP、序列化和流能力,不启用 TLS 后端; -- 真正创建 HTTPS client 的 app、service 或 adapter 必须在自身依赖声明中显式选择 `reqwest/rustls`,只使用 `reqwest::Url` 的 contract/assembly 路径不加载 TLS; -- capability crate 的每个 Reqwest owner feature 必须独立带齐 `reqwest/rustls`,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; +- workspace 级 `reqwest` 只统一版本并关闭默认 feature,不替任何客户端选择 HTTP/2、序列化、表单、流、代理或 TLS 能力; +- 真正创建 client 的 app、service 或 adapter 必须在自身依赖声明中显式选择实际使用的 Reqwest feature 和 `reqwest/rustls`;只使用 `reqwest::Url` 的 contract/assembly 路径不加载传输能力; +- capability crate 的每个 Reqwest owner feature 必须独立带齐自己的数据/传输 feature 与 `reqwest/rustls`,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; - 边界检查以 Cargo metadata 的解码结果看护全部直接 consumer,并检查 resolved Reqwest feature union,防止传递依赖重新激活 Native TLS; - 不并列启用 native-tls 兼容栈。只有真实产品场景无法由 Rustls 平台证书验证承载时,才以明确行为证据评审替换方案,而不是重新叠加第二后端。 +### 3.5 稳定契约 crate 按消费能力切片 + +稳定 DTO、port 和纯契约继续由原 contract/execution crate 拥有;当同一 crate 的公开表面覆盖多个互不相关的 +能力域时,优先在原 crate 内用 additive owner feature 隔离源码和可选依赖,不为了构建数字迁移 runtime owner +或复制公共类型。feature 关闭时 API 不可见是明确的编译期契约变化;feature 开启后必须保留原公开路径、序列化 +形状和错误语义。 + +- contract crate 的 `default` 保持空并由目标 crate 的边界契约看护;consumer 继承该空默认值,只选择实际 + 消费的切片,不在每条 normal/dev/build/target dependency edge 重复关闭 default features; +- feature 名描述稳定能力,例如 Agent API、workspace/terminal/remote/Git port、协议 bridge 或 Computer Use + contract,不描述某个临时调用方、PR 或测试; +- 不提供 `full`、`service-ports`、`all-contracts` 等重新合并全部表面的 umbrella。只有多个 owner 共同消费且 + 无法合理拆开的稳定复合类型可以保留一个窄 aggregate,并明确列出其组成; +- 同一依赖的 Cargo feature 会在完整图中相加,因此窄 consumer 必须独立 `cargo check/test`。完整产品构建 + 只能证明组合闭包可用,不能证明单个 consumer 的声明完整; +- 边界检查看护目标 crate 的空默认、feature surface、consumer edge 及强/弱 feature 转发;不得依赖包名文本 + 而漏掉 rename、optional、dev/build 或 target-specific edge。 + ## 4. 依赖 owner 与准入检查 第三方库应位于调用外部系统或实现具体能力的最低合理 owner: @@ -95,6 +130,13 @@ Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品 - DTO、事件和端口属于 contracts,保持行为轻量且不得依赖上层; - Assembly 选择和连接 capability,不实现具体 adapter、OS 或 service 细节; - app 只拥有入口、平台生命周期和产品呈现,不复制可复用服务逻辑。 +- 稳定 contract 与具体运行时实现位于同一 crate 时,优先用单一 owner feature 隔离实现依赖;不得为 + feature-free DTO、fallback 或纯事实强拉解析器、全局状态、网络或 host adapter。 +- 平台 host 已直接拥有的 adapter 不通过 Assembly facade 二次依赖或 re-export;上层只共享稳定 + contract,具体 emitter/transport 由真实 host 直接构造。 +- Services Core 的 feature-free surface 只保留同步稳定 contract、JSONC 和路径规范化;诊断脱敏、 + Diff 计算和异步 workspace 文本读取分别由 `diagnostics`、`diff`、`workspace-text-runtime` owner + 选择。Assembly 可用同名 feature 保留兼容 facade,但不得把这些实现依赖放回默认闭包。 新增依赖或显著扩大已有依赖 feature 时,PR 描述至少给出: diff --git a/docs/development/i18n.md b/docs/development/i18n.md index 5e254900a2..b77828eab9 100644 --- a/docs/development/i18n.md +++ b/docs/development/i18n.md @@ -41,7 +41,7 @@ pnpm run i18n:audit | Web UI i18n runtime or namespace loading | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile Web i18n runtime | `pnpm --dir src/mobile-web run type-check` | | Installer frontend i18n runtime | `pnpm --dir BitFun-Installer run type-check` | -| Backend i18n runtime | `cargo test -p bitfun-core i18n -- --nocapture` | +| Backend i18n runtime | `cargo test -p bitfun-core --no-default-features --features i18n-runtime --lib i18n -- --nocapture` | Do not add process-only execution plans to version control. Keep durable rules in this file and `docs/architecture/i18n.md`; keep temporary rollout notes out diff --git a/docs/development/releasing.md b/docs/development/releasing.md new file mode 100644 index 0000000000..c1b7909b88 --- /dev/null +++ b/docs/development/releasing.md @@ -0,0 +1,63 @@ +# Release Channels + +BitFun packages use an immutable build-time release channel. End users do not +switch channels at runtime. + +## Stable + +Stable releases continue to be driven by a version bump on `main`. The +`Release On Version Bump` workflow creates `vMAJOR.MINOR.PATCH` and dispatches +`Desktop Package` with the default `stable` channel. + +## Beta + +Run `Desktop Package` manually with: + +- `tag_name`: the immutable release tag, for example `v0.2.18-beta.1`; +- `checkout_ref`: the commit or branch to build when the tag does not exist; +- `release_channel`: `beta`; +- `upload_to_release`: disabled for internal Actions artifacts, enabled for a + public GitHub pre-release. + +Beta versions target the next stable version. If stable is `0.2.17`, the first +candidate is `0.2.18-beta.1`, not `0.2.17-beta.1`. Do not use SemVer build +metadata in a published package version; the release already records the Git +commit separately. + +Public beta assets are stored on the immutable version tag. After every asset +and signature is verified, the workflow updates only the `latest.json` asset on +the `channel-beta` pre-release. Beta Desktop builds read that pointer and fall +back to `https://openbitfun.com/release/beta/latest.json`. +The beta release contains Desktop and Installer assets only. CLI and Relay +floating releases remain stable-only. + +The selected ref must resolve to a commit in the protected `main` history. The +workflow pins that SHA before dispatching platform jobs and rejects an existing +release tag if it points somewhere else. Configure the signing secrets and the +public beta approval policy so untrusted pull-request code cannot access them. +This protected-history requirement applies to the canonical `GCWing/BitFun` +repository; forks may run packaging from their own test branches. A fork beta +uses that fork's `channel-beta` release as both updater origins, so it cannot +silently consume or mutate the canonical beta channel. + +A stable release promotes the beta pointer only when its version is not older +than the current beta. This lets beta users move from `0.2.18-beta.N` to +`0.2.18` without allowing a late workflow to roll the channel backward. + +Beta and stable currently share the same bundle identity and data directories. +Installing beta replaces stable; side-by-side installation is not supported. + +## Mirror + +The mirror script defaults to stable. Run a separate beta sync with: + +```bash +BITFUN_RELEASE_CHANNEL=beta scripts/openbitfun-release-sync.sh +``` + +The beta invocation writes below `/release/beta` and intentionally skips the +stable-only CLI and Relay floating manifests. + +Production cron must run this in-repo script from the BitFun checkout. Do not +create a detached copy. Host paths, Nginx, and the rest of the origin restore +steps live in [`deploy/openbitfun-host/README.md`](../../deploy/openbitfun-host/README.md). diff --git a/docs/development/ui-testids-CN.md b/docs/development/ui-testids-CN.md index 3691485092..38ff31dc63 100644 --- a/docs/development/ui-testids-CN.md +++ b/docs/development/ui-testids-CN.md @@ -182,6 +182,7 @@ | Chat 输入可编辑区域 | `chat-input-textarea` | 富文本可编辑区域。 | | Chat 发送按钮 | `chat-input-send-btn` | 输入有效时的发送动作。 | | Chat 取消按钮 | `chat-input-cancel-btn` | 存在时用于取消进行中的发送/生成。 | +| Chat 继续中断任务按钮 | `chat-input-continue-interrupted-btn` | 输入框为空时继续最近一个可恢复的中断 Turn。 | | Chat 输入工作区条 | `chat-input-workspace-strip` | composer 上方的活动工作区条。 | | Chat 输入目标切换器 | `chat-input-target-switcher` | 目标/模式切换器。 | | Chat 输入图片条 | `chat-input-image-strip` | 已附加图片条。 | diff --git a/docs/development/ui-testids.md b/docs/development/ui-testids.md index ce11e0d619..8f00a6ffdb 100644 --- a/docs/development/ui-testids.md +++ b/docs/development/ui-testids.md @@ -183,6 +183,7 @@ Avoid adding IDs to these surfaces unless there is a clear automated workflow. | Chat input editable region | `chat-input-textarea` | Rich text editable region. | | Chat send button | `chat-input-send-btn` | Send action when input is valid. | | Chat cancel button | `chat-input-cancel-btn` | Cancels in-progress send/generation when present. | +| Chat continue interrupted button | `chat-input-continue-interrupted-btn` | Resumes the latest recoverably interrupted turn when the composer is empty. | | Chat input workspace strip | `chat-input-workspace-strip` | Active workspace strip above composer. | | Chat input target switcher | `chat-input-target-switcher` | Target/mode switcher. | | Chat input image strip | `chat-input-image-strip` | Attached image strip. | diff --git a/docs/features/session-runtime-usage-report-design.md b/docs/features/session-runtime-usage-report-design.md index 08d32d3cd9..2c423febd9 100644 --- a/docs/features/session-runtime-usage-report-design.md +++ b/docs/features/session-runtime-usage-report-design.md @@ -689,8 +689,8 @@ Risk and drift controls: Required verification before merging P0: -- `cargo check -p bitfun-core` -- `cargo test -p bitfun-core session_usage -- --nocapture` +- `cargo check -p bitfun-core --no-default-features --features agent-runtime` +- `cargo test -p bitfun-core --no-default-features --features agent-runtime --lib session_usage -- --nocapture` - Focused CLI command tests or manual CLI smoke if no existing helper test harness exists. - `pnpm run lint:web` - `pnpm run type-check:web` @@ -1038,8 +1038,8 @@ Risks and mitigations: Verification: -- `cargo test -p bitfun-core session_usage -- --nocapture` once tests exist. -- `cargo check -p bitfun-core`. +- `cargo test -p bitfun-core --no-default-features --features agent-runtime --lib session_usage -- --nocapture` once tests exist. +- `cargo check -p bitfun-core --no-default-features --features agent-runtime`. - DTO tests for workspace identity, report scope, in-progress reports, cache-unavailable coverage, and redaction metadata. ### Task 2: Non-model-visible local report item diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index e84c31ea5d..f0996738b0 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -1,8 +1,8 @@ # BitFun 编译与依赖治理计划 -> 最近核实:2026-08-10 +> 最近核实:2026-08-14 > -> 实现复核基线:`gcwing/main@e63084bc5` +> 实现复核基线:`gcwing/main@76f8b89e2` > > 性能 A/B 基线:`gcwing/main@1f538b96d` > @@ -16,9 +16,14 @@ | 结论 | 说明 | |---|---| -| 服务测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;选中的 `local-storage`、MCP、基础 SSH 闭包从 16 个集成 executable 降到 8 个 | -| Agent Runtime 基线不再隐藏全量 capability union | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;MCP、Remote Connect、Browser/Web、Git、模型目录等由产品入口显式组合,三平台 normal/build 闭包各减少 105–110 个版本化 package instance | -| 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择的隐含能力,Windows/macOS/Linux 分别减少 10/13/22 | +| 集成测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;External Sources 的 adapter/assembly target 从 22 降到 7;五个 Contracts/AI/Assembly crate 又从 28 降到 10,feature、平台和外部系统失败域保持独立 | +| Agent Runtime 基线不再隐藏重型 capability | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;文档转换与订阅认证也改为产品显式 modifier。在最新主线 A/B 中,三平台 normal/build 闭包进一步减少 69/64/110 个版本化 package instance | +| App Server 不继承未消费能力 | App Server 保持现有 Agent/Git/外部来源 handler 边界,不再因 Core 基线携带文档转换和本地订阅凭据,三平台闭包减少 61/56/78 | +| SDK Host 使用显式能力闭包 | SDK Host 保留当前本机协议和工具能力,但不再通过 `product-full` 携带协议未暴露的 Remote Connect、SSH、Function Agent 等能力;Windows/macOS/Linux normal/build 闭包减少 66/68/76 | +| Core 默认值不再代表完整产品 | Core library 的默认 feature 集合为空,能力内部实现依赖回到实际 owner;最新三平台 feature-free 闭包继续减少 30/31/31 个 package instance | +| ACP 按实际宿主拆分角色 | 兼容默认值仍为 client + server;Desktop 只选择 client,CLI 选择两者。Desktop 独立构建不再编译 ACP 的 4,211 行 server/runtime 源码,产品协议与远程行为不变 | +| 完整产品行为保持 | `product-full` 显式组合全部 capability owner;Core 自身不再携带 Desktop host transport而减少 1 个 package,Desktop 闭包不变。CLI 显式保留原先实际生效的 Oniguruma 高亮后端,ACP 默认组合保持原能力 | +| 默认 feature 责任已集中 | 仓内空默认由被依赖 crate 和边界检查负责;workspace member 的 `default-features = false` 从 70 处降到 6 处,仅保留 ACP 两个窄 consumer 与 Relay 独立 Docker 上下文的 4 处必要声明。第三方默认策略尽可能回到 workspace 根,根 lock 只删除 11 个 package、无新增 | | Installer 删除未使用的直接能力 | 独立 manifest 的直接 dependency 从 18 降到 10,Windows normal/build 闭包减少 6;不把 Installer 并入根 workspace,本 PR 按要求不提交其生成 lockfile | | focused test 仍保持精确 | 同 owner、feature、平台和进程语义的源文件进入分组 target;使用 `--test ::` 运行单模块 | @@ -44,7 +49,9 @@ ## 3. 当前基线 -### 3.1 服务层测试拓扑 +### 3.1 集成测试链接拓扑 + +#### Services 本轮只合并 owner 和运行边界相同的测试。`session_write_lock_contracts` 依赖当前测试 executable 启动异常退出子进程,因此继续保持独立;不同 feature 的服务测试也不合并。 @@ -72,6 +79,24 @@ clean/rebuild,表中为均值。时间是方向性证据,不是硬阈值。 MCP 的 2→1 candidate 也做过同口径 A/B,但冷构建和 owner 重建均无可区分的提速;streamable HTTP 测试还拥有真实 loopback TCP/SSE/超时失败域,因此最终继续保持两个 target,不计入本轮收益。 +#### External Sources adapters 与 assembly + +同一 crate 内、相同依赖和运行边界的静态来源合同通过 wrapper target 收敛;测试正文逐字迁移,仍可用 +`--test ::` 聚焦到单个来源模块。OpenCode 的 MCP 子进程、受管插件服务和 Node 脚本 +runtime 分别保留独立 target,避免为了减少链接次数混合不同环境、超时和故障语义。 + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| OpenCode adapter | 8 | 4 | 130 | +| Claude Code adapter | 4 | 1 | 51 | +| Codex adapter | 3 | 1 | 47 | +| External Sources assembly | 7 | 1 | 30 | +| 合计 | 22 | 7 | 258 | + +这部分只减少 15 个重复链接的 test executable;未新增 dependency、feature 或 CI 命令,也不以当前证据 +宣称 wall-clock 提速。Cargo 边界检查锁定显式 target、wrapper-only root、leaf 唯一引用和 crate-level cfg, +避免后续新增测试静默绕过分组拓扑。 + 可重复确认的产物变化如下;`test executable` 包含每个 crate 的 lib test harness,因此比 integration target 多 1。PDB 大小会随工具链变化,只比较同次 A/B: @@ -80,6 +105,26 @@ target 多 1。PDB 大小会随工具链变化,只比较同次 A/B: | local-storage | 13 → 6 | 25.2 → 19.2 MiB | 135.7 → 91.9 MiB | | 基础 Remote SSH | 3 → 2 | 3.9 → 2.8 MiB | 53.5 → 43.8 MiB | +#### Contracts、AI adapters 与 Product Assembly + +五个纯合同/组装 owner 使用显式 wrapper target;AI 的纯协议测试与真实 loopback SSE 测试继续分成两个 +失败域,Product Domains 的默认、Plugin Source、External Sources、Function Agent 与 MiniApp 也继续按 +owner feature 分开。270 个 integration tests 不变,模块过滤仍可聚焦单个 leaf: + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| `core-types` | 4 | 1 | 10 | +| `runtime-ports` | 5 | 1 | 21 | +| `product-domains` | 9 | 5 | 179 | +| `ai-adapters` | 7 | 2 | 29 | +| `product-capabilities` | 3 | 1 | 31 | +| 合计 | 28 | 10 | 270 | + +对应五个 lib test harness 的 test executable 总数从 33 降到 15;workspace integration target 从 +91 降到 73。该变化减少 18 次重复链接,但单叶变更会重链所属分组,因此这里只报告确定的拓扑收益, +不在缺少同机多轮 A/B 时宣称 wall-clock 提速。边界检查锁定 exact leaf、owner feature 和空 +`required-features` 的默认 target,避免以后用 `product-full` 扩大测试闭包。 + ### 3.2 依赖与 feature 闭包使用 `cargo tree -e normal,build` 按目标平台统计版本化 package instance;它衡量进入编译图的 @@ -94,26 +139,247 @@ package/version,不等同于实际秒数。路径 package 因 A/B worktree 路 | Desktop | 792 → 792 | 807 → 807 | 892 → 892 | 完整产品继续使用既有跨平台截图行为,本轮不以扩大根 lock 依赖宇宙换取单平台闭包下降 | | Installer | 333 → 327 | — | — | Windows 独立 workspace;直接 dependency 18 → 10 | +下表前五项延续 `gcwing/main@734e5b05f` 的已核实 A/B,SDK Host 行以 +`gcwing/main@22f5411e7` 为变更前基线。三平台 target 分别为 `x86_64-pc-windows-msvc`、 +`aarch64-apple-darwin` 和 `x86_64-unknown-linux-gnu`;计数先移除 Cargo tree 的重复展示标记 +`(*)`,再按 package/version 去重: + +| 本轮闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `agent-runtime` | 343 → 274 | 330 → 266 | 375 → 265 | 文档扩展识别保留;转换和本地订阅凭据明确不可用 | +| App Server | 490 → 429 | 477 → 421 | 508 → 430 | 现有 handler/DTO 保持,未消费的两个能力退出 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 显式恢复 `document-read` 与 `subscription-auth` | +| CLI | 649 → 649 | 649 → 649 | 672 → 672 | 显式保持原有能力 | +| ACP | 589 → 587 | 574 → 572 | 594 → 592 | 保持原有能力,同时退出 Reqwest 未使用的 `mime_guess`/`unicase` | +| SDK Host | 578 → 512 | 565 → 497 | 609 → 533 | 保留本机 SDK profile、九组工具 owner、外部静态来源和 ring TLS;退出未暴露的 Remote Connect、SSH、Function Agent 与完整产品附属能力 | + +本轮没有新增 crate 或第三方 package;SDK Host 只把已有测试依赖 `rustls` 调整为进程入口实际使用的 +normal dependency,根 lock package 集合不变。前两类收益来自 `anydoc` 及其文档解析/压缩依赖, +以及订阅凭据的 keyring/加密/本地存储依赖;SDK Host 的收益来自未公开远程能力对应的 +SSH、密钥和连接子图退出。完整产品 package 集合不变, +因此这里只报告依赖图收敛,不宣称 `product-full` wall-clock 提速。 + +以下是以 `gcwing/main@3d8ee4bc0` 为变更前基线、使用同样三个 target triple 和去重口径复算的最新 A/B: + +| 最新闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `--no-default-features` | 104 → 102 | 93 → 91 | 92 → 90 | 删除 Core 不再消费的 `tokio-stream`、`urlencoding` 直接边 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 完整产品仍从真实 adapter/service owner 获得两项依赖 | +| CLI | 649 → 643 | 649 → 642 | 672 → 665 | 删除未调用的 `syntect-tui`/`dashmap`;显式保留既有 Oniguruma 高亮后端 | +| Desktop | 792 → 790 | 807 → 805 | 892 → 887 | 删除从未注册、没有调用方的 global-shortcut 插件和 ACL | +| MiniApp Market | 205 → 204 | 208 → 207 | 206 → 205 | 删除服务从未消费的 `urlencoding` 直接边 | +| Page Function tests | 38 → 35 | 38 → 35 | 38 → 35 | 删除同步 Rust 测试未使用的 dev-only Tokio 闭包 | + +以下继续以 `gcwing/main@7345619ac` 为变更前基线,记录 Core 默认值与 ACP 角色边界收敛。三平台、 +依赖类型与去重口径与上表一致: + +| 本轮闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core 隐式默认 | 570 → 93 | 557 → 82 | 601 → 81 | library 默认不再冒充完整产品;只保留 feature-free facade 与 build dependency | +| Core `--no-default-features` | 102 → 93 | 91 → 82 | 90 → 81 | 四条 Core 直接边退出并回到实际 owner;package 集合净减 9 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | Desktop/Server 等完整产品入口仍显式恢复全部能力 | +| ACP 默认兼容组合 | 587 → 587 | 572 → 572 | 592 → 592 | 默认仍精确组合 client + server,独立 ACP 测试与外部兼容行为不缩小 | +| Desktop | 790 → 790 | 805 → 805 | 887 → 887 | package 集合不变;ACP 仅编译 client 模块,server/runtime 4,211 行退出该 package build | +| CLI | 643 → 643 | 642 → 642 | 665 → 665 | 显式选择 ACP client + server,既有 CLI-hosted server 行为不变 | + +ACP 两个新角色的当前独立闭包为 client 397/390/391、server 533/518/539(Windows/macOS/Linux)。 +它们不能直接相加:Cargo 会对共同依赖去重。该拆分的确定收益是 Desktop 独立构建不再编译 ACP server +模块,而不是 Desktop package 数下降;因此不宣称完整 Desktop wall-clock 提速。 + +Core 空闭包减少的 9 个 package instance 主要来自 `base64` 与 `futures` 的独有子图;`regex` 和 +`tokio-util` 的 Core 直接边虽然已经移除,但 package 仍由 feature-free contracts/services 路径传递保留。 +因此本轮证明的是 direct owner 边界收敛,不能把四条 direct edge 都描述成 package 完全退出。 + +Core 的 bare/default 编译契约本轮发生了有意变化:仓内产品消费者此前已经全部关闭默认 feature 并显式 +选择 owner,因此运行行为不变;仓外若有 path/git consumer 依赖旧的隐式完整表面,需要显式选择 +`product-full`,或改为列出实际使用的 owner。该迁移属于编译期契约变化,不能描述成对未知外部 consumer +完全无影响。 + +Syntect 不能机械地只删适配层:旧 feature union 同时启用 `regex-fancy` 与 `regex-onig` 时,实际由 +Oniguruma 后端处理。当前 manifest 直接选择 `regex-onig`,因此运行后端、默认 syntax/theme 和 +Syntect→Ratatui 样式转换保持不变,同时让未生效的 fancy 后端与未消费的 YAML loader 退出。 + Package instance 会低估“同一个大 crate 少编译了多少 feature 代码”。在 Windows `agent-runtime` 闭包中,`bitfun-services-integrations` 的 Cargo active feature 从 61 个降到 6 个, 只保留 `workspace-search` 及其 5 个直接依赖 feature;`bitfun-product-domains` 从 13 个降到 5 个, 只保留 Agent Runtime 实际使用的 external-subagent contract slice。Function Agent、MiniApp、 Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式恢复。 -根 `Cargo.lock` 与实现复核基线保持一致,package 记录不增加;Installer 自己生成的 -`BitFun-Installer/src-tauri/Cargo.lock` 本 PR 不提交。 +上一轮根 `Cargo.lock` 从 1176 降到 1169,精确删除 `syntect-tui`、`custom_error`、`fancy-regex`、 +`yaml-rust`、`linked-hash-map`、`tauri-plugin-global-shortcut` 和 `global-hotkey`;没有新增、升级或 +降级 package。本轮 feature/角色边界调整保持该 lockfile 字节不变,也没有新增第三方 package。 +Installer 自己生成的 `BitFun-Installer/src-tauri/Cargo.lock` 不提交。 + +以下以 `gcwing/main@a4e06cae3` 为变更前基线,完成 Core feature-free 依赖基线的剩余 owner +收敛。统计仍使用相同三个 target triple、`normal,build` 边和版本化 package instance 去重口径: + +| 最新闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `--no-default-features` | 93 → 63 | 82 → 51 | 81 → 50 | Fluent runtime、Tool Contracts、host transport、诊断/Diff 实现与非基线 Tokio 子图退出;locale/config/path 稳定契约保留 | +| Core `product-full` | 570 → 569 | 557 → 556 | 601 → 600 | 显式恢复 I18n、Agent 与全部产品 owner;仅不再经 Core 携带 Desktop host transport | +| Desktop | 790 → 790 | 805 → 805 | 887 → 887 | Desktop 原本已直接拥有 transport,完整宿主依赖图和行为不变 | +| Services Core feature-free | 22 → 15 | 22 → 15 | 22 → 15 | Regex、Similar 与 Tokio 全部退出;只保留同步稳定 contract、JSONC 与路径规范化 | +| Codex Adapter | 79 → 72 | 78 → 71 | 78 → 71 | 只使用同步 workspace path contract,不再继承 Services Core 的文本实现依赖 | +| Static Hook Support | 72 → 65 | 73 → 66 | 72 → 65 | feature-free Services Core 依赖边不再携带未消费的文本实现依赖 | +| Claude Code Adapter | 85 → 82 | 84 → 81 | 84 → 81 | Markdown owner 仍保留 Regex;Diff 与 Tokio 退出 | +| OpenCode Adapter | 141 → 140 | 140 → 139 | 140 → 139 | 既有 Markdown/Tokio owner 保留,仅未消费的 Similar 退出 | + +Core 的直接 Tokio capability 从 `fs/io-util/macros/net/rt/sync/time` 收敛为 `fs/sync`;异步宏、 +网络以及产品 runtime 能力由现有 `agent-runtime`、`browser-control`、`debug-log`、`lsp` owner +显式选择。feature-free Core 仍通过 `bitfun-services-core/json-io` 获得其原子 JSON 写入所需的 +`rt/time`,测试使用的多线程运行时只留在 dev-dependency。`runtime-ports/permission` 没有被机械移出:`GlobalConfig` 与项目权限文件公开 DTO +确实在 feature-free facade 中使用它,继续保留比制造条件 API 更符合契约稳定性。 + +Services Core 的 feature-free profile 进一步不再依赖 Tokio。同步路径规范化仍可直接使用;异步受限 +workspace 读取由 `workspace-text-runtime` 选择,诊断日志脱敏与本地 Diff 分别由独立 owner feature +选择。Core 继续通过同名 feature 保留原 facade,`product-full` 显式组合两者,避免把依赖收敛变成 +完整产品的源码或行为回归。 + +本轮有意收紧了数项编译期契约。直接使用 feature-free Core 的仓外 consumer 若调用 +`I18nService`,必须显式选择 `i18n-runtime`;旧的 +`bitfun_core::infrastructure::events::TransportEmitter` 导入路径不再提供,host adapter 应直接从 +`bitfun-transport` 导入。直接依赖 feature-free `bitfun-services-core` 的 consumer 若调用诊断脱敏、 +本地 Diff 或异步 workspace 文本 API,必须分别选择 `diagnostics`、`diff` 或 +`workspace-text-runtime`;通过 Core 兼容 facade 调用前两者时选择同名 Core feature。仓内真实 consumer +已全部迁移,完整产品的运行时事件、翻译和服务行为不变,但这些源码迁移不能描述为对未知外部 +consumer 零影响。 + +该轮结束时根 lock package 为 1169,新增、升级、降级 package 均为 0。由于 Core 删除本地 +`bitfun-transport` 直接边,`Cargo.lock` 的 Core dependency record 同步删除这一行;这是依赖边 +收敛,不是 package 集合增长,也不通过保留无 owner 的 optional dependency 伪造字节不变。 + +以下以 `gcwing/main@aeb8099ae` 为变更前基线,继续收敛两个稳定契约 crate 的源码与依赖可见面。 +统计仍使用 `x86_64-pc-windows-msvc`、`aarch64-apple-darwin`、`x86_64-unknown-linux-gnu`,并按 +`cargo tree -e normal,build --no-dedupe` 的版本化 package instance 去重: + +| 契约/消费闭包 | Windows | macOS | Linux | 边界结果 | +|---|---:|---:|---:|---| +| Runtime Ports feature-free | 21 → 13 | 21 → 13 | 21 → 13 | Agent API、服务端口、插件与脚本端口按 owner feature 退出;common marker/结果契约保留 | +| Tool Contracts feature-free | 25 → 18 | 25 → 18 | 25 → 18 | ACP/MCP bridge、Computer Use 与 element-token 源码按独立 feature 退出 | +| Terminal Core | 94 → 92 | 82 → 80 | 81 → 79 | 只选择 terminal port,不再编译无关 Session/Git/Remote workspace 表面 | +| Plugin Runtime Client | 22 → 16 | 22 → 16 | 22 → 16 | 只选择 plugin-runtime contract,不再继承完整 Runtime Ports 表面 | +| Tool Runtime | 78 → 78 | 66 → 66 | 65 → 65 | 真实 owner 仍消费稳定 handle 组合,package 闭包不变 | +| ACP client | 385 → 385 | 378 → 378 | 379 → 379 | 依赖重叠使 package 数不变;组合闭包启用 ACP bridge 与 Core 所需 Computer Use contract,不启用 MCP/element-token | +| ACP server | 522 → 522 | 507 → 507 | 528 → 528 | 组合闭包启用 Core 所需 Computer Use/MCP contract,不继承 ACP client bridge 或 element-token | + +Package instance 会低估同一 crate 内源码切片的收益。Runtime Ports 变更前约 6,045 行源码全部进入任意 +consumer;当前 feature-free 非测试表面约 314 行,约 95% 的 Agent/服务/plugin/script 端口源码退出最小编译。 +Tool Contracts 约 6,846 行源码中,ACP/MCP bridge、Computer Use 与 element-token 共约 2,861 行 +(41.8%)由各自 feature 控制。这里报告的是解析、类型检查和增量重编译输入收敛;没有重复的冷/热构建 +样本,因此不宣称完整产品 wall-clock 提速。完整产品和真实 owner 显式恢复原 API,runtime ownership、 +wire shape 与行为不变。 + +`bitfun-agent-runtime` 的直接 Runtime Ports 边也不再选择其源码未消费的 `remote-exec-port` 与 +`tool-runtime-handles`;Core 的 `agent-runtime` owner 仍显式选择二者,因为具体 assembly 路径确实使用这些句柄。 + +这次切片包含有意的编译期可见性收紧:直接依赖 Runtime Ports 或 Tool Contracts 的仓外 consumer 需要 +显式选择所用 port/bridge/Computer Use/element-token feature。启用相应 owner 后原公开路径保持不变, +仓内 consumer 已逐项闭合;不能把 feature-free 构建下的源码不可见描述为对未知外部 consumer 零影响。 + +本轮没有新增、升级或降级第三方 package,根 `Cargo.lock` 保持字节不变;也没有新增 CI job、矩阵或命令。 +Runtime Ports 的 owner-specific integration targets 保持彼此独立,避免为了减少 executable 数重新制造 +feature union。 + +以下以 `gcwing/main@a4d944e5b` 为变更前基线,统一默认 feature 的责任位置。该主线相对前次测量 +没有 Cargo 输入变化;统计继续使用同样三个 +target triple 和 `normal,build` 版本化 package instance 去重口径;它只描述编译图,不直接代表墙钟时间: + +| 闭包 | Windows | macOS | Linux | 结果 | +|---|---:|---:|---:|---| +| Core feature-free | 63 → 63 | 51 → 51 | 50 → 50 | 空默认与显式 owner feature 不变;只删除 consumer 侧冗余开关 | +| Core `product-full` | 569 → 569 | 556 → 556 | 600 → 600 | 完整产品 owner 集合与运行能力不变 | +| CLI | 642 → 640 | 641 → 639 | 664 → 662 | Markdown 只使用 parser,退出未消费的 `getopts` 与 HTML renderer | +| Desktop | 790 → 790 | 805 → 793 | 887 → 887 | BitFun 的 macOS Objective-C 直接依赖边按实际 imports 选择 feature;Tauri/wry 等第三方仍合并自身所需 feature | +| Relay Service | 190 → 190 | 193 → 193 | 192 → 192 | 独立 Docker manifest 保持显式版本与默认策略,闭包不变 | +| Services Integrations feature-free | 26 → 26 | 28 → 28 | 27 → 27 | Qrcode 的 PNG/SVG 能力仍由 `remote-connect` owner 显式选择 | + +workspace member 中显式 `default-features = false` 从 70 处降到 6 处:62 个仓内空默认重复声明删除, +MiniApp/Skin Market 的 2 个 SQLx 声明改为继承 workspace 根策略。剩余 6 处中,两处是 Desktop/CLI 对 ACP +的必要例外,因为 ACP 有意保留 `client + server` 兼容默认,而两个产品入口必须分别选择 client-only 和 +双角色;另 4 处属于 Relay Server、Relay Service 和 Page Function Runtime,它们会被 Docker 单独复制、 +无法继承 workspace 根,因此继续显式声明 Tokio、SQLx 与 RquickJS 的默认策略。 + +第三方默认收敛只处理有源码与构建证据的依赖:Futures 保留 `std`,Tracing 保留 `std`,Chrono 保留 +`serde + clock + std`;Tokio Stream 的真实 consumer 只使用 feature-free 的 Receiver/iter wrappers; +Remote Connect 显式选择 qrcode 的 `image + svg`;Pulldown-Cmark 不启用 CLI 未使用的命令行/HTML renderer; +BitFun 的 macOS Objective-C binding 直接依赖边只选择源码导入的类型和所需 `std`;Cargo 的最终 +feature union 仍包含 Tauri、wry、notification、updater 等第三方路径的需求。根 `Cargo.lock` 从 1169 +降到 1158,新增 package 为 0;删除项仅来自 +Pulldown-Cmark 的 `getopts`/HTML escape 子图和未使用的 Objective-C framework binding。未关闭 Axum、Tauri、 +Clap、Tracing Subscriber、Notify 等默认即产品契约或缺少独立收益证据的依赖。 | 状态 | 范围 | 处理结论 | |---|---|---| | 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、workspace Tokio 最小基线 | 不重复治理 | -| 本轮完成 | Core Agent Runtime capability、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 | -| 当前不动 | App Server / Server | 只为保持现有 handler 编译显式声明其已消费的 Core owner;不在改造稳定前继续拆其生产路径 | +| 本轮完成 | Core 空默认与 capability-local 工具依赖、ACP client/server 角色、Core Agent Runtime capability、文档转换与订阅认证 modifier、SDK Host 显式 owner closure、Installer/CLI/Desktop/Core/MiniApp Market/Page Function 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella;根 lock 不增加 package | +| 本轮完成 | App Server schema owner | wire DTO 与 TypeScript 导出收敛到 protocol;server 只保留 handler 与 owner-to-wire 转换,正式 client 继续由独立 client crate 持有 | | 明确保留 | Desktop screenshots backend | 替换方案必须同时保持三平台坐标/权限/区域捕获语义且不增加根 lock package;当前候选不满足 | | 明确保留 | `portable-pty 0.8/0.9` | 非 OHOS 与 OHOS 的平台兼容选择,不为去重破坏 | 重复版本数量只用于发现候选,不能直接转化为治理任务。`oxc`、`rquickjs`、vendored `git2`、`sherpa-onnx` 等重依赖都有真实 capability owner;只有某个产品入口不消费对应能力时,才允许让它退出该入口的构建图。 -### 3.3 CI 与本地验证 +以下以 `gcwing/main@d1d1dd9e8` 为变更前基线,把 `bitfun-agent-runtime` 内部长期共存的完整 Runtime、 +DeepResearch 纯编号和原生 Hook 配置/执行拆成同 crate 的 owner feature。没有新增 crate 或兼容 `full` +umbrella;统计仍按三个既定 target triple、`normal,build` 版本化 package instance 去重: + +| 闭包 | Windows | macOS | Linux | 边界结果 | +|---|---:|---:|---:|---| +| Agent Runtime feature-free | 76 → 1 | 79 → 1 | 78 → 1 | 空默认只保留 crate 本身;所有运行时源码和第三方依赖由 owner feature 选择 | +| Agent Runtime `agent-runtime` | 76 → 76 | 79 → 79 | 78 → 78 | 完整 Runtime API、Hook 执行和依赖闭包保持不变 | +| Agent Runtime `native-hook-settings` | 76 → 10 | 79 → 10 | 78 → 10 | Hook 配置解析不再编译进程执行、Session、Tool 和 Runtime Services | +| Services Integrations `deep-research` | 77 → 36 | 80 → 38 | 79 → 37 | 只保留纯编号、WorkspaceFS port 与报告 IO;完整 Agent Runtime 退出 | +| Services Integrations `hook-import` | 121 → 89 | 112 → 79 | 111 → 78 | 只复用 Hook settings contract;Session/Agent lifecycle 退出 | +| Core `product-full` | 569 → 569 | 556 → 556 | 600 → 600 | 完整产品显式恢复全部真实 owner,package 闭包不缩水 | + +测试闭包也按真实 owner 收敛:Agent Runtime 的 DeepResearch target 在 `normal,build,dev` 口径从 +`76/79/78` 降到 `6/6/6`,Hook settings target 降到 `10/10/10`;Services DeepResearch 测试从 +`80/85/85` 降到 `44/48/47`。为保持 feature 与进程失败域,Agent Runtime 显式 integration target 从 +5 个增为 7 个:DeepResearch 与 Hook settings 各自独立,Unix Hook 子进程测试继续独立;没有把这些 +focused target 加进 CI,也没有新增 job、矩阵或仓库级命令。 + +该轮同时修复 DeepResearch post-turn IO 的既有远程路径错误:Core 现在把当前 session 注入的 +`WorkspaceFileSystem` 传给 Services Integrations,本地和 Remote SSH 都通过同一 provider 读取报告、 +引用表并写回 sidecar;远程逻辑路径不再被 Windows host 当成本机 `Path` 探测。provider 缺失时明确 +跳过并记录 warning,不允许回退到宿主文件系统。纯编号算法、报告内容、sidecar schema 和本地 +best-effort 行为保持不变。路径拼接语义也由 `WorkspaceFileSystem` provider 持有:本地 provider +继承宿主路径规则,Remote SSH 对绝对、home 和相对 workspace root 均保持 POSIX 分隔符。 + +`bitfun-services-integrations::deep_research::run_for_session_workspace` 与 +`try_renumber_research_report` 的公开函数签名现在要求显式传入 `WorkspaceFileSystem`;仓内唯一生产 +调用者已迁移。这是为消除远程路径宿主回退而做的有意源码契约收紧,不能描述为对未知的仓外 path/git +consumer 零影响。完整 Rust Runtime SDK 同时将兼容版本提升为 v6:仓外 embedder 需要在 +`bitfun-agent-runtime` 依赖上显式选择 `agent-runtime`;启用后原 `sdk` 公开路径和行为保持不变。 + +这里仍只报告依赖图和 focused-test 输入,不宣称完整产品 wall-clock 提速。根 `Cargo.lock` package +集合与字节均不变,新增/升级/降级 package 为 0;`.github` 和 `ci.yml` 不变。 + +### 3.3 App Server TypeScript schema owner + +变更前,Web `gen:types` 先编译 protocol,再编译完整 `bitfun-app-server`,后者会把 +Core、Agent Runtime 和具体 Service handler 闭包带入前端类型生成。最新同代码状态的 +Frontend Build 样本中,`gen:types` 约 430 秒,占 `build:web` 约 484 秒的 88.8%;其中 +protocol 导出约 42.58 秒,App Server 导出约 6 分 26 秒。单次 CI 样本只用于定位热点, +不能独立证明合入后的 wall-clock 收益。 + +本轮将 behavior-light wire DTO 和 TS derive 统一到 `bitfun-app-server-protocol`,server 端 +保留 Core/Runtime/Service owner 到 wire read model 的显式转换。既有 `app-server::schema` +继续兼容 re-export,`bitfun-app-server/ts` 继续兼容转发;隐藏且无生产 consumer 的第二套 +client 删除,正式 client 仍由 `bitfun-app-server-client` 拥有。兼容面是已有平铺类型路径、 +method 和 wire shape;旧 server-only inherent/`From` helper 不是版本化公共 SDK,仓内调用已 +迁入 adapter 或稳定 contract 方法。三平台 +`normal,build,dev` 版本化 package instance 去重结果如下: + +| TS 导出闭包 | Windows | macOS | Linux | +|---|---:|---:|---:| +| 变更前:完整 App Server | 433 | 429 | 437 | +| 变更后:App Server Protocol | 153 | 159 | 159 | +| 收敛 | -280 | -270 | -278 | + +Web 实际引用的 19 个直接类型及其递归导入闭包保持生成内容字节一致;不再把 95 个历史 +导出文件全部视为兼容面。根 `Cargo.lock` package 集合和版本不变,只从 App Server package +记录删除不再直接消费的依赖边;`.github`、CI job 和矩阵均不变。 + +### 3.4 CI 与本地验证 - 现有 CI 已覆盖 workspace check、Core/Desktop lib、平台敏感 owner 测试和独立 runtime/CLI 验证;本轮不新增 job、矩阵或 changed-path 分类器。 - CI 不负责穷举所有测试;新增验证只有具备独立 owner、平台矩阵或失败归因价值时才进入既有流水线,否则由最近模块的 focused command 维护。 @@ -131,9 +397,15 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 | CI 拓扑 | Rust job 不再等待完整前端构建,自建 Tauri 检查所需资源目录 | | 依赖收敛 | Desktop 直接 image 版本和 Reqwest TLS 双栈已治理 | | Agent Runtime 闭包 | Core 基线不再暗带具体 capability;完整产品和 CLI 显式保持原能力,ACP 退出未选择闭包 | +| Core/ACP 默认与角色 | Core 默认 feature 为空;ACP 默认精确保持 client + server,Desktop client-only、CLI 双角色均由现有边界检查锁定 | +| 重型可选能力 | 文档转换和本地订阅凭据由弱 modifier 细化已有 runtime owner;Core 基线和 App Server 退出未消费闭包 | | Installer 闭包 | 删除 8 个未使用直接 dependency;独立 workspace 和发布生命周期不变,本 PR 不提交其生成 lockfile | -| Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target | +| SDK Host 闭包 | 从 `product-full` 改为与当前协议/构造路径一致的显式 Core owner closure;保留 ring TLS 初始化,本机 SDK 行为不变,未交付的远程执行能力不再进入构建图 | +| Agent Runtime 测试 | 28 个 integration executable 已完成职责聚合;当前 7 个 target 中,DeepResearch、Hook settings 与 Hook 子进程按 feature/进程失败域独立,其余保持聚合 | | Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | +| External Sources 测试 | 四个 adapter/assembly crate 从 22 个 target 收敛到 7 个;MCP、插件服务和脚本 runtime 继续独立 | +| Contracts/AI/Assembly 测试 | 五个 crate 从 28 个 target 收敛到 10 个;AI loopback 与纯协议、Product Domains 各 owner feature 保持独立 | +| 未使用直接依赖 | 删除 CLI/Desktop/Core/MiniApp Market/Page Function 的失效直接边;保留 Syntect 实际 Oniguruma 后端,根 lock 只减 7 个 package | 内置 Agent 内容已经移到无第三方依赖的 `bitfun-agent-content`,减少了 Core build-script 工作; 但 Core 仍直接依赖该 crate。没有足够产品收益前,不为消除这一编译指纹引入动态 provider、 @@ -147,7 +419,7 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 |---|---| | CI 收敛 | 先积累多次相同 owner 的 step wall-clock、cache hit/miss 和失败历史;只有能证明收益且不会静默缩小覆盖时再独立设计 | | Desktop 截图后端 | 新候选同时满足三平台行为等价、区域捕获无性能回退、系统依赖可 feature-gate,且根 lock package 不增加 | -| App Server / Server | 当前改造合入并稳定后,重新锁定最新生产调用链和可信 owner 边界 | +| App Server / Server | 观察 protocol 单一 schema owner 合入后的 Frontend Build 样本;没有新的生产 owner 或稳定行为收益前,不继续拆 handler 路径 | | 其他产品入口重型 capability | 证明入口不消费该能力,具备 typed unsupported/fallback 行为,并能让一个真实重依赖子图退出 | | 重复 native/sys 库版本 | 同一 owner 能升级收敛且三平台打包/ABI 有证据;不因版本数字重复强行 patch | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 9a0f0af315..69faf6c53c 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -25,7 +25,7 @@ | CLI / Desktop / ACP | CLI 与 ACP 已分别提交对应 `DeliveryProfile`、消费 Runtime Parts/SDK,并选择经过评审的 Core owner feature closure;Desktop 仍启用 `bitfun-core/product-full`,主交互已消费由现有 owner 构造的窄口径 SDK 接口 | 三个入口仍复用单一 Core runtime owner;完整 Desktop profile 和剩余兼容操作仍需逐项迁移 | | Server | 当前生产路由只形成 health/info/ping 基线 | 没有插件状态或独立产品组装完整流程 | | Server / Remote / Web / Mobile Web / SDK profile | 当前为空计划、未接入入口或仅有 preview 测试 | 不得据枚举值宣称产品能力已交付 | -| Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v3 preview 接口和 smoke test | 发布边界仍需真实嵌入方证明 | +| Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v6 preview 接口和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用消息结构或静态候选扩张稳定 ABI | | Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded bind、静态 fallback 和任务生命周期由 Desktop 窄宿主端口持有 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位已完成并由生命周期与边界测试保护 | | CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期及本地确定性流式模型夹具驱动的活动 turn 进程测试,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;本地模型 HTTP 403 授权拒绝、流中断后的重试失败、Linux PTY/Windows ConPTY Chat resize/取消、`exec` Ctrl+C 及 Patch I/O 失败已有分层回归,真实供应商审批交互、macOS 活动 PTY 与 OS 级终端故障注入仍需补齐 | @@ -129,7 +129,7 @@ Core 只为插件兼容提供已有 owner 的窄接口:真实工具、类型 | 文档与仓库边界 | `pnpm run check:repo-hygiene`,`node --test scripts/check-core-boundaries.test.mjs`,`node scripts/check-core-boundaries.mjs` | | 入口 profile 迁移 | 对应 app 的 check/test、入口级 smoke、profile/服务可用性断言、旧路径等价用例 | | Relay 共享 owner / Cargo 方向 | standalone 与 embedded focused tests、Cargo 依赖方向失败用例、Desktop 宿主启停/失败回滚/静态缓存行为测试 | -| Agent Runtime / SDK | `cargo test -p bitfun-agent-runtime`,最小 no-`bitfun-core` 嵌入测试 | +| Agent Runtime / SDK | `cargo test -p bitfun-agent-runtime --no-default-features --features agent-runtime --lib`,最小 no-`bitfun-core` 嵌入测试 | | 插件首个执行切片 | runtime ports、`PluginRuntimeClient`、生态 adapter、`ScriptToolRuntime`、Plugin Host 与真实固定 fixture 的端到端调用 | | CLI | `cargo check -p bitfun-cli`,`cargo test -p bitfun-cli`,结构化协议和 package smoke | diff --git a/docs/plans/tui-app-server-decoupling-refactor-plan.md b/docs/plans/tui-app-server-decoupling-refactor-plan.md index dbd5dfc96d..3f0b96f22b 100644 --- a/docs/plans/tui-app-server-decoupling-refactor-plan.md +++ b/docs/plans/tui-app-server-decoupling-refactor-plan.md @@ -1,8 +1,8 @@ # TUI 与 App Server 解耦重构计划 -> 状态:Phase 0-4 已完成当前定义的边界、协议基础、核心聊天、配置管理和外部集成接口迁移;Phase 5 Shared App Server 目标待评审。 +> 状态:Phase 0-4 已完成当前定义的边界、协议基础、核心聊天、配置管理和外部集成接口迁移;Phase 5 Embedded direct-runtime 迁移待实现,Shared App Server 目标待评审。 > -> 当前状态基线:2026-08-09。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 +> 当前状态基线:2026-08-13。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 > > 本文只记录当前差距、阶段和完成证据。稳定架构约束见相邻架构文档;Phase 0 的历史盘点已失效,不再作为当前能力清单。 @@ -18,8 +18,8 @@ 本计划只迁移交互式 TUI 的产品后端调用: 1. TUI 保留终端输入、状态、渲染和 controller-local effect。 -2. TUI 通过 app-local `TuiBackend` 使用产品后端,不直接依赖 Core、Runtime 实现、Service、全局 singleton 或私有 IPC operation。 -3. Embedded TUI 使用 `AppServerTuiBackend`;Shared TUI 在 Shared App Server 交付前使用 `SharedTuiBackend` compatibility adapter。 +2. TUI 当前通过 app-local `TuiBackend` 使用产品后端;Phase 5 再把它拆成 Runtime port 与按 domain 注入的管理接口。`TuiAgentClient` 不直接依赖 Core、Runtime 实现、具体 Service、全局 singleton 或私有 IPC operation;view/reducer 也不执行 backend I/O。 +3. Embedded TUI 当前使用 `AppServerTuiBackend`;Shared TUI 当前使用 `SharedTuiBackend` 映射 private Runtime IPC v17。Phase 5 才引入 `TuiRuntimePort`、`DirectRuntimeTuiRuntime` 和 Shared IPC Runtime adapter,并把管理用例移到 backend composition 的 owner service/provider 接口。 4. App Server 只适配稳定合同,不接管 Runtime、Service 或 Product Domain 的业务所有权。 5. Headless `exec`、ACP、Peer Host 和公开 SDK 保留各自经评审的 adapter。 @@ -38,7 +38,7 @@ 当前 head 有两条交互式 TUI 后端路径: ```text -Embedded TUI +Embedded TUI(当前) -> TuiAgentClient -> TuiBackend -> AppServerTuiBackend @@ -56,62 +56,93 @@ Shared TUI (--shared) -> Runtime API / owners ``` -两条路径统一的是 TUI 可见的行为端口。Shared compatibility adapter 会把 Runtime IPC 的结果和事件映射为 `TuiBackend` 使用的类型,但它没有运行 `BitfunAppServer`,也不是 Shared App Server transport。 +当前两条路径共用的是包含 Runtime 与管理方法的单体 `TuiBackend`。`AppServerTuiBackend` +把 Embedded 请求委托给 App Server client;`SharedTuiBackend` 把 Session/chat 请求映射到 +private Runtime IPC v17,并直接持有具体 `AppManagementService` 承接 Host-local 管理能力。 +Phase 3/4 已让 controller 使用 typed backend API,不代表 Phase 5 的 port 拆分或 adapter 接线已经完成。 -Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 `TuiBackend` 的 owner-specific typed API。Phase 4 进一步迁移了 External Source、native/external Hook、Account、Settings Sync 和 Worktree 管理面。具体 DTO/owner 适配由 App Server 的 `AppManagementService` 持有,并由 Host 显式装配;Embedded TUI 经 App Server 访问既有 owner,TUI controller 不再直接访问这些 compatibility owner。 +### 2.2 Approved Embedded target -Shared 的 Session/chat/mode authority 继续映射 v17;Host 实际提供的本机管理 capability 由 `SharedTuiBackend` 委托同一个具体 management service。当前 Shared Host 提供 Phase 4 的 External Source V1 和 Hook 管理,但不注入 Account/Settings Sync 或 Worktree owner;这些能力返回 typed unsupported。Remote workspace 对所有 controller-local management capability fail closed,不回落到控制端本机。Phase 4 完成表示接口边界已迁移,不表示所有 deployment 的 capability 完全相同。Phase 4 之后新增的 External Application V2 控制面目前只在 Embedded App Server 接线,Shared Runtime 明确 unsupported,不重新打开 Phase 4 的旧 owner 直连预算。 +Phase 5 将当前单体 backend 拆成下面的 app-local composition: -### 2.2 Proposed target +```text +TuiAgentClient + -> TuiBackend composition + -> TuiRuntimePort + -> DirectRuntimeTuiRuntime + -> SharedIpcTuiRuntime + -> owner-owned service/provider interfaces (management only) +``` + +这是已批准但尚未交付的 Phase 5 目标。`TuiRuntimePort` 将只覆盖 Embedded 和 Shared +都需要、且当前 private Runtime IPC v17 已经承载的 Runtime 行为:initialize/health、 +Session、Turn、Permission/UserInput、shell、compact/undo/redo/reload、usage/settlement、 +workspace reference/diff、lineage、fork、当前 Session 的 model/mode 更新、agent mode +catalog 和事件订阅。Phase 5 完成后,`DirectRuntimeTuiRuntime` 将 direct Runtime 映射到 +该 port,Shared IPC adapter 将 v17 的结果和事件映射到同一组 TUI semantic types;后者 +不运行 `BitfunAppServer`,也不是 Shared App Server transport。 + +Model catalog/CRUD、Skill、Subagent、MCP、Account、Settings Sync、Worktree、External +Source 和 Hook 不因为被 TUI 使用就进入 `TuiRuntimePort`,也不需要一个总括性的 +`TuiManagementPort`。backend composition 按 domain 注入各 owner 已有的稳定 service/provider +trait;Embedded 直接使用同进程 owner service,Shared 使用 Host-local service/provider +adapter。只有原始 service 接口暴露内部类型、无法表达 TUI 所需的权限/上下文/unsupported, +或需要跨部署稳定 DTO 时,才在 owning crate 或 adapter 内增加最薄的 facade。不得把 +`AppManagementService` 整体搬入 direct adapter,也不得让 controller/view 直接依赖具体 +service 实现。缺少 provider 的 Shared/Remote 场景返回 typed unsupported,禁止静默回落控制端本机。 + +Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 TUI-facing typed API。Phase 4 进一步迁移了 External Source、native/external Hook、Account、Settings Sync 和 Worktree 管理面。当前这些方法仍位于单体 `TuiBackend`;Embedded 经 App Server 的 `AppManagementService` wiring 调用 owner,Shared 则由 `SharedTuiBackend` 委托其持有的具体 `AppManagementService`。Phase 5 将按 owner 归属把管理用例拆到 service/provider 接口,不建立管理总接口;TUI controller 仍不直接访问 compatibility owner。 + +Phase 5 中,`DirectRuntimeTuiRuntime` 将实现 `TuiRuntimePort`,可以依赖 Rust Runtime SDK 暴露的稳定 typed facade,但不得把 Runtime 内部类型、Core singleton 或事件队列 owner 暴露给 TUI。管理面 backend 可以直接调用 owner-owned 的稳定 service/provider trait;若该接口暴露内部类型,或需要 TUI-specific DTO、权限、上下文和 capability 裁剪,才在 owning crate 抽取薄 facade。不得把 `AppManagementService` 原样搬到 CLI,也不得由 direct adapter 复制其业务状态或策略。 + +当前 Shared 的 Session/chat/mode authority 由 `SharedTuiBackend` 映射 v17;Phase 5 完成后才由 Shared IPC Runtime adapter 实现 `TuiRuntimePort`。Host 实际提供的本机管理 capability 当前由 `SharedTuiBackend` 委托具体 `AppManagementService`,Phase 5 再改为 backend composition 按 domain 调用 Host-local service/provider adapter。当前 Shared Host 提供 Phase 4 的 External Source V1 和 Hook 管理,但不注入 Account/Settings Sync 或 Worktree owner;这些能力返回 typed unsupported。Remote workspace 对所有 controller-local management capability fail closed,不回落到控制端本机。Phase 4 完成表示 typed API 和 wiring 已迁移,不表示所有 deployment 的 capability 完全相同,也不表示 Phase 5 已完成。Phase 4 之后新增的 External Application V2 控制面目前只在 Embedded App Server 接线,Shared Runtime 明确 unsupported,不重新打开 Phase 4 的旧 owner 直连预算。 -若 [App Server 目标架构](../architecture/app-server-architecture.md) 通过评审,交互式 Rich Client 的目标路径为: +### 2.3 Optional Shared App Server proposal + +Web 当前继续通过自己的 loopback WebSocket App Server 入口,不经过 TUI composition: ```text -TUI renderer / input / state / local effects - | - v - TuiBackend trait - | - v - AppServerTuiBackend adapter - | - v - AppServerClient - | - Host-selected transport - / \ - in-memory Embedded controlled Shared local - \ / - v - App Server - | - v - Runtime API / Services / Product Domain owners +Web UI -> Web Host -> loopback WebSocket App Server + -> Runtime API / owner ports + +Shared Rich Client(Phase 6 candidate) + -> AppServerClient + -> candidate private Pipe / UDS + -> Shared App Server Host + -> Runtime API / owner ports ``` -Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controller/lease、事件恢复、断连取消、`outcome_unknown`、frame 限制和空闲退出达到行为等价前继续保留。是否最终删除 v17 由等价测试、性能数据、真实 Rich Client 消费方和回滚证据决定,不能只依据 schema 相同或 adapter 已存在。 +Shared App Server 仍是 Phase 6 待评审提案,不是 Phase 5 的既定结果。Private Runtime IPC +v17 在候选 transport 的鉴权、实例身份、controller/lease、事件恢复、断连取消、 +`outcome_unknown`、frame 限制和空闲退出达到行为等价前继续保留;评审也可以决定长期保留 +v17。Embedded direct-runtime 不替换 v17,也不要求 direct facade 与 wire DTO 相同;两者只需 +满足同一行为合同。 ## 3. 当前能力矩阵 状态定义: - **已交付**:生产 handler/client 已接线,并被当前 Embedded TUI 路径使用。 -- **兼容映射**:Shared TUI 通过 Runtime IPC v17 和 `SharedTuiBackend` 提供等价 TUI 用例,但没有经过 App Server wire。 +- **兼容映射**:Shared TUI 通过 Runtime IPC v17 和 `SharedTuiBackend` 提供与 Embedded 对照的 TUI Runtime 用例,但没有经过 App Server wire。 - **部分交付**:已有合同或 handler,但 Host 能力、恢复、安全或 TUI 调用路径仍不完整。 - **未迁移**:当前 TUI 仍使用既有 compatibility owner 路径,或尚无生产接口。 - **本地保留**:属于 TUI 或 controller-local effect,不迁移。 +本矩阵的 Embedded 列记录 Phase 5 之前的 App Server 基线。Phase 5 完成后,Runtime 用例由 +`DirectRuntimeTuiRuntime` 调用 Runtime typed facade;管理用例由 backend composition 调用 +对应 owner service/provider。表中的行为合同和 Shared 对照场景保持不变。 + ### 3.1 核心聊天与 Session | TUI 用例 | Embedded App Server | Shared v17 compatibility | 当前结论 | | --- | --- | --- | --- | | 初始化、版本、健康 | `app/initialize`、`app/health` | adapter 根据 v17 握手结果合成 TUI-facing initialize/health | Embedded 已交付;Shared 尚不是 App Server connection | | Agent、Permission 事件 | `agent/event`、`agent/permissionEvent` | IPC 事件桥映射为 `AppServerEvent` | 两边均可驱动当前核心 TUI;底层恢复合同不同 | -| Config 事件 | `config/event` | 当前 Shared bridge 不投影 Config 事件 | Embedded 已接线;Shared 的 TUI-facing 管理 capability 来自 Host 装配的 App Server management service,不代表 v17 已有 Config 事件 | +| Config 事件 | `config/event` | 当前 Shared bridge 不投影 Config 事件 | Embedded 已接线;Shared 的 TUI-facing 管理 capability 当前由 `SharedTuiBackend` 委托其具体 `AppManagementService`,不代表 v17 已有 Config 事件;Phase 5 再拆成 owner service/provider adapter | | 流失效与重同步 | `app/eventStreamState`、`app/syncEvents`、`session/sync` | adapter 投影 connection-local cursor、invalidation/resync 和 closed | 已有连接内 cursor/sync;没有跨连接持久 replay/resume | | Session list/create/sync | `agent/listSessions`、`agent/createSession`、`session/sync` | list/create/atomic restore operation | 已交付;sync 包含 Runtime 状态、transcript、workspace binding 和 pending Permission | | Session delete/rename/fork | typed App Server methods | v17 controller-scoped operations | 已交付或兼容映射;Shared 继续执行 controller/idle 规则 | -| Model/mode update | `session/updateModel`、`session/updateMode` | v17 current-controller operations | Session update wire 已覆盖;Embedded 与 Shared 都经 typed 管理 API 取得目录/defaults,Shared 的目录来自 Host 装配的 App Server management service | +| Model/mode update | `session/updateModel`、`session/updateMode` | v17 current-controller operations | Session update wire 已覆盖;当前 Embedded 经 `AppServerTuiBackend` 提交,Shared 经 `SharedTuiBackend -> Runtime IPC v17` 提交;Phase 5 完成后再分别由 Direct/Shared Runtime adapter 实现 `TuiRuntimePort` | | Submit/cancel/steer | typed Agent methods | v17 Turn operations | 已交付或兼容映射 | | User Shell/UserInput | `agent/runUserShellCommand`、`agent/submitUserAnswers` | v17 typed operations | 已交付或兼容映射;执行和权限仍由 Runtime owner 持有 | | Permission pending/respond | typed Permission methods/events | v17 pending/respond and event stream | 已交付或兼容映射 | @@ -131,14 +162,14 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll | Domain | 当前状态 | 当前结论 / 后续 | | --- | --- | --- | -| Mode/Model 管理 | Embedded 经 typed mode catalog 和 model list/get/add/update/delete/default API;read DTO 只含 secret configured metadata,mutation 使用 preserve/replace/clear | Phase 3 已完成;Shared mode catalog 来自 Runtime Host,model 管理由 Host 装配的 App Server management service 转发,Session model mutation 仍由 v17 owner 提交 | -| Skill/Subagent | TUI 经 typed list/toggle API 消费 visible/manageable read model;App Server management service 委托既有 registry owner | Phase 3 已完成;Embedded 与 Shared 共用具体 service,Shared capability 明确属于本机 CLI compatibility scope | -| MCP | 当前 TUI 用例经 typed catalog/status/toggle/add/delete/external decision/conflict API;read projection 与 Debug 输出不暴露凭据 | Phase 3 已完成当前定义;Shared 通过当前 CLI 进程的本地 MCP compatibility service 保留迁移前管理行为。该 service 的 MCP 进程状态和 tool registry 不会即时重配已经运行的 Shared Runtime Host;要取得 Host 侧新状态仍需显式的同步/restart contract,不能把本地 toggle 描述成 v17 远端控制 | -| External Source/Tool/Command/Agent | TUI 经 typed snapshot/control/review、conflict choice、command expansion 和事件接口消费既有 owner;后续 External Application V2 snapshot/review/action 已在 Embedded 接线 | Phase 4 当前定义已完成;Shared 保留 V1 本机 compatibility,V2 明确 unsupported,Remote 不回落本机 | -| Hooks | TUI 经 typed native overview 与 external snapshot/plan/apply/mutate API 消费既有 owner | Phase 4 已完成;native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 继续分离,Remote 明确 unsupported | +| Mode/Model 管理 | Embedded App Server 提供 typed mode catalog 和 model list/get/add/update/delete/default API;read DTO 只含 secret configured metadata,mutation 使用 preserve/replace/clear | Phase 3 已完成 typed API 与 wiring;Shared mode catalog 来自 Runtime Host,model 目录/管理由 `SharedTuiBackend` 持有的具体 `AppManagementService` 提供,Session model mutation 由 `SharedTuiBackend` 提交给 v17 owner;Phase 5 再拆分 owner service/provider 与 Runtime port | +| Skill/Subagent | 当前 `TuiBackend` 提供 typed list/toggle API 和 visible/manageable read model;Embedded 经 App Server wiring,Shared 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 3 已完成 typed API 与 wiring;Shared capability 明确属于本机 CLI compatibility scope,Phase 5 再拆成 owner service/provider adapter | +| MCP | 当前 `TuiBackend` 提供 typed catalog/status/toggle/add/delete/external decision/conflict API;read projection 与 Debug 输出不暴露凭据 | Phase 3 已完成当前定义;Shared 由 `SharedTuiBackend` 委托当前 CLI 进程的具体 `AppManagementService`,以本地 MCP compatibility service 保留迁移前管理行为。该 service 的 MCP 进程状态和 tool registry 不会即时重配已经运行的 Shared Runtime Host;要取得 Host 侧新状态仍需显式的同步/restart contract,不能把本地 toggle 描述成 v17 远端控制 | +| External Source/Tool/Command/Agent | 当前 `TuiBackend` 提供 typed snapshot/control/review、conflict choice、command expansion 和事件接口;Embedded 经 App Server wiring,Shared V1 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 4 当前定义已完成;Shared 保留 V1 本机 compatibility,V2 明确 unsupported,Remote 不回落本机;Phase 5 再拆成 owner service/provider adapter | +| Hooks | 当前 `TuiBackend` 提供 typed native overview 与 external snapshot/plan/apply/mutate API;Embedded 经 App Server wiring,Shared 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 4 已完成 typed API 与 wiring;native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 继续分离,Remote 明确 unsupported;Phase 5 再拆成 owner service/provider adapter | | Account/Settings Sync | typed snapshot/login/finalize/logout 与 sync start/snapshot/cancel/local-changed 已接线;凭据不进入 read model 或 Debug 输出 | Phase 4 接口迁移已完成;Embedded Host 注入共享 `AccountRuntime`,App Server 直接做 domain-to-wire 适配;当前 Shared Host 未注入并返回 typed unsupported | | Worktree | typed repository status、bind/release 和 operation identity 已接线 | Phase 4 接口迁移已完成;Embedded Host 注入 Worktree owner,当前 Shared Host 与 Remote workspace 明确 unsupported | -| Desktop/Web Host 安全 | WebSocket Host 仅为 loopback 单用户;Desktop 尚未迁移为 App Server Host | Host allowlist、身份/作用域、真实 limits 与平台 capability provider | +| Desktop/Web Host 安全 | WebSocket Host 仅为 loopback 单用户;Desktop 当前仍使用 Tauri adapter,独立 direct Runtime 迁移尚未实施 | Host allowlist、身份/作用域、真实 limits 与平台 capability provider | ### 3.4 本地保留 @@ -164,7 +195,7 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll | `src/crates/interfaces/app-server-protocol` | behavior-light method、DTO、wire error、event envelope 和角色定义 | | `src/crates/interfaces/app-server-client` | 类型化请求、事件分发和 host-supplied transport 抽象 | | `src/crates/interfaces/app-server` | server 生命周期、生产 handler 注册、Runtime/domain 与 wire 转换、错误映射 | -| `src/apps/cli` | `TuiBackend`、Embedded/Shared adapter 选择、transport 和进程生命周期、TUI-local effect | +| `src/apps/cli` | 当前拥有 `TuiAgentClient`、单体 `TuiBackend`、`AppServerTuiBackend`、`SharedTuiBackend`、transport 和进程生命周期、TUI-local effect;Phase 5 再引入 Runtime port 与按 domain 注入的 owner service/provider composition | | Runtime/Service/Product Domain owners | Session、Turn、Permission、Workspace、配置和其他业务权威事实 | 边界规则: @@ -173,6 +204,10 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll - `bitfun-app-server` 可依赖生产 handler 所需的明确 owner feature,但禁止选择 `bitfun-core/product-full`。 - Host 负责 transport、认证、作用域、真实 capability/limits、平台能力和进程生命周期。 - handler 只做合同校验、DTO 转换和错误映射,不持有第二份业务权威状态。 +- Phase 5 引入的 `TuiRuntimePort` 只抽取 Embedded/Shared 共同需要的 Runtime 行为;不定义总括性的 `TuiManagementPort`。 +- Phase 5 将管理面按 domain 拆到 owner-owned 的稳定 service/provider trait;只有需要 DTO、权限/上下文 + 适配或 capability 裁剪时才增加薄 facade,不能把具体 service 实现或 `AppManagementService` + 整体暴露给 TUI。 - DTO 提取不代表 Runtime owner 迁移。 ## 5. 分阶段状态 @@ -183,17 +218,18 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll | --- | --- | --- | --- | --- | | Phase 0:边界 | `TuiBackend`、behavior-light protocol/client crate、source/Cargo guard 已建立 | Core boundary tests 和 dependency checks | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | | Phase 1:协议基础 | initialize/health、typed events、connection-local cursor、resync、稳定错误和 Embedded connection 已接线 | App Server protocol/client/server focused tests | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | -| Phase 2:核心聊天 | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | +| Phase 2:核心聊天(旧路径) | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义,作为迁移基线 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | | Phase 3:配置管理 | TUI controller 不再访问 config/registry/MCP compatibility owner;secret-safe typed APIs 完成,CLI Host adapter 可保留显式 compatibility forwarding | owner tests、App Server contract tests、CLI behavior tests | 已完成当前定义 | 本变更的 protocol/client/server/CLI focused tests 与 Core boundary checks | | Phase 4:外部集成 | External Source、Hook、Account、Settings Sync、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 已完成当前定义 | [PR #2146 checks](https://github.com/GCWing/BitFun/pull/2146/checks)、zero-budget contract 与 Core boundary checks | -| Phase 5:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | +| Phase 5:Embedded direct-runtime | `TuiRuntimePort` 与 owner service/provider 接入边界拆分完成;Embedded TUI 删除 App Server client/server 与 in-memory transport,改用 direct Runtime adapter;管理面不进入 Shared IPC,旧路径随切换删除 | Runtime port 对 Shared v17 operation 的覆盖测试;direct-runtime 与 Shared v17 的 Runtime 行为测试;各管理 service/provider 的 capability/unsupported 测试 | 待实现 | - | +| Phase 6:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | ### 5.1 Phase 0-2 已交付摘要 -- `TuiAgentClient`、Startup 和 `ChatMode` 只消费 app-local `TuiBackend`。 -- Embedded Host 在专用 OS 线程的 current-thread Tokio runtime + `LocalSet` 中运行 private `BitfunAppServer`,TUI 保持在原多线程 runtime。 -- `AppServerTuiBackend` 通过正式 `AppServerClient` 和 in-memory transport 完成核心用例。 -- `SharedTuiBackend` 将相同用例映射到 private Runtime IPC v17;TUI client/controller 不引用 IPC operation。 +- `TuiAgentClient`、Startup 和 `ChatMode` 只消费 app-local `TuiBackend`;Runtime 调用和管理调用均不下沉到 view/reducer。 +- Embedded Host 当前在专用 OS 线程的 current-thread Tokio runtime + `LocalSet` 中运行 private `BitfunAppServer`,TUI 保持在原多线程 runtime;这是 direct-runtime 迁移前的基线。 +- `AppServerTuiBackend` 通过正式 `AppServerClient` 和 in-memory transport 完成核心用例,后续由 direct Runtime composition 替换。 +- `SharedTuiBackend` 将相同 Runtime 行为映射到 private Runtime IPC v17;TUI client/controller 不引用 IPC operation。将这些方法抽取为 Runtime port 属于 Phase 5。 - App Server 核心 handler 覆盖 sync、turn、Permission、revert、context、usage、settlement、Workspace 和 lineage;Config 事件也已在 Embedded connection 接线。 - Runtime IPC v17 为当前 parity 增加 restore Runtime 状态、usage、settlement 和本地命令 transcript 记录;没有增加 replay、observer、通用 controller transfer 或公开 SDK 能力。 - capability 声明列出当前注册方法,但 Host-specific availability 和方向性 limits 仍是后续收紧项。 @@ -215,8 +251,8 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll - `app-server-protocol` 提供 Mode、Model、Skill、Subagent 和 MCP 的 owner-specific DTO 与 method;model read model 不返回 secret 值,model mutation 使用 preserve/replace/clear 语义。 - App Server 由 Host 显式注入具体 `AppManagementService`,按 `tui.modes`、`tui.models`、`tui.skills`、`tui.subagents` 和 `tui.mcp` 发布真实 availability;service 缺失或 unavailable 时返回带 capability id 的 structured unsupported。 -- `AppManagementService` 位于 App Server server wiring,复用现有 config、registry、MCP 和 external-source owner,不成为第二个业务 owner;Startup 与 Chat controller 只经 `TuiAgentClient -> TuiBackend` 调用这些管理用例。 -- `SharedTuiBackend` 继续映射 v17 mode catalog,并将 Model、Skill、Subagent 和 MCP 管理委托 Host 装配的具体 App Server management service。v17 不承载这些目录、CRUD 或 defaults;Shared 发布的是 adapter-scoped 本地 capability,current-Session model update 仍按 v17 controller/idle/outcome-unknown 合同提交给 Runtime Host。Shared MCP service 的运行态只属于当前 CLI 进程,不宣称可以即时控制已经运行的 Shared Runtime Host。 +- `AppManagementService` 位于 App Server server wiring,复用现有 config、registry、MCP 和 external-source owner,不成为第二个业务 owner;Startup 与 Chat controller 只调用 `TuiBackend` 的 typed 方法。Phase 5 才把这些方法按 domain 拆到 owner service/provider,且不会建立 direct TUI 的总管理接口。 +- `SharedTuiBackend` 继续映射 v17 mode catalog,并将 Model、Skill、Subagent 和 MCP 管理委托其持有的具体 `AppManagementService`。v17 不承载这些目录、CRUD 或 defaults;Shared 发布的是 adapter-scoped 本地 capability,current-Session model update 仍按 v17 的 controller/idle/outcome-unknown 合同提交给 Runtime Host。Phase 5 再以 owner service/provider adapter 和 Shared IPC Runtime adapter 替换这段单体 wiring。Shared MCP service 的运行态只属于当前 CLI 进程,不宣称可以即时控制已经运行的 Shared Runtime Host。 - Core boundary budgets 已移除 Phase 3 owner 直连债务,并要求 Startup 的 Subagent 管理继续使用 typed backend。 ### 5.3 Phase 4 @@ -235,14 +271,23 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll 交付摘要: - `app-server-protocol`、client 和 production handlers 已提供 External Source、native/external Hook、Account、Settings Sync 与 Worktree 的 owner-specific typed API;side-effecting 请求使用 operation identity,External Source 与 Hook mutation 保留 owner revision/stale 合同,Settings Sync 提供显式取消与 snapshot。 -- `TuiAgentClient`、Startup 和 Chat controller 只经 `TuiBackend` 调用这些用例。Phase 4 涉及的 `bitfun_core`、account/account-sync compatibility marker 已从 controller 文件移除,对应 Core boundary budget 固定为零。 +- `TuiAgentClient`、Startup 和 Chat controller 只经单体 `TuiBackend` 的 typed API 调用这些用例;Embedded 由 `AppServerTuiBackend` 委托 App Server wiring,Shared 由 `SharedTuiBackend` 委托 v17 或其具体 `AppManagementService`。Phase 4 涉及的 `bitfun_core`、account/account-sync compatibility marker 已从 controller 文件移除,对应 Core boundary budget 固定为零;Runtime port 与按 domain 的 management composition 仍留给 Phase 5。 - Embedded Host 显式注入共享 `AccountRuntime` 并启用 App Server 内建的本机 Worktree 映射;App Server management service 直接适配 owner,不定义 `AccountManagementHost` 或持有第二份账户、同步、外部来源、Hook、Worktree 权威状态。CLI 的窄 `AccountRuntimeHost` 只实现 daemon、Relay/Peer 路由宿主效果,Session 备份通过独立端口读取 Agent Runtime compatibility owner。 - Shared adapter 只发布 Host 实际可用的 capability。External Source V1 与 Hook 管理可使用当前本机 compatibility service;Account/Settings Sync、Worktree、Remote workspace 和后续未接线的 External Application V2 返回 typed unsupported,不静默回落本机。 -- Phase 4 未扩展 private Runtime IPC v17,也未改变 Phase 5 的评审门槛。 +- Phase 4 未扩展 private Runtime IPC v17,也未改变 Phase 6 的 Shared transport 评审门槛。 ### 5.4 Phase 5 -Phase 5 不以“删除 v17”为起点。建议顺序: +Embedded direct-runtime Phase 5 建议顺序: + +1. 按当前 Shared IPC v17 operation 集合冻结窄 `TuiRuntimePort`,定义统一的 TUI semantic request/result/event/error;direct facade 与 v17 wire 不要求共享 DTO。 +2. 将当前包含 Runtime 和管理面方法的单体 `TuiBackend` 拆为 backend composition:Runtime 调用进入 `TuiRuntimePort`,Model/Skill/Subagent/MCP/Account/Settings/Worktree/External Source/Hook 按 domain 进入各自 owner service/provider 接口;不创建 `TuiManagementPort` 总接口。 +3. 逐项检查管理 service 的暴露面:能直接复用稳定 owner-owned trait 的直接注入;暴露内部类型或需要 TUI DTO、权限/上下文、capability 裁剪的,才抽取最薄 facade。`AppManagementService` 仅保留为 App Server wiring,不原样迁入 CLI。 +4. 为 `DirectRuntimeTuiRuntime` 和 Shared IPC adapter 实现 Runtime port;再将 Embedded Host 从 `EmbeddedAppServerHost` 切换为 direct Runtime Host,删除 in-memory transport、App Server thread 和 initialize/health wire handshake。 +5. 按 Chat、Session、Permission/UserInput、Workspace、Config/Management 垂直切片迁移并验证事件订阅、pending Permission、取消、unknown outcome、workspace/execution binding、错误映射和 Host shutdown 回收。 +6. 完成 direct Runtime 的性能、升级兼容和跨入口行为验证后删除旧 App Server;不保留 rollback adapter,direct adapter 不支持的能力必须返回 typed unsupported。 + +Shared App Server Phase 6 不以“删除 v17”为起点。建议顺序: 1. 在 Shared Host 中增加默认关闭的 App Server local transport。 2. 两条 transport 复用同一 Host-scoped connection authority、controller registry、Session 事件过滤、operation identity/deadline/cancel 和未知结果登记。 @@ -278,19 +323,19 @@ Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https:// | Workspace | binding、references、diff、remote facts | | Lineage | tree、descendant transcript、settlement、targeted cancellation | | Failure | unsupported、lag、invalidated、disconnect、deadline、`outcome_unknown` | -| Deployment | Embedded App Server 与 Shared v17 compatibility 的 TUI behavior parity | +| Deployment | 当前覆盖 Embedded App Server 与 Shared v17 compatibility;Phase 5 切换到 direct-runtime 并删除旧 Embedded App Server | -Shared App Server 实现后,同一 fixture 必须增加 Embedded App Server、Shared App Server 和 v17 rollback 三方验证,直到 v17 被正式保留或删除。 +Embedded direct-runtime 实现后,同一 fixture 必须覆盖 direct Runtime 和 Shared v17;迁移前可用旧 Embedded App Server 建立基线,但不维护第三条回滚路径。Shared App Server 实现后再增加候选 transport 路径。 ## 7. 完成定义 只有同时满足以下条件,才能宣布 TUI/App Server 解耦完成: -1. Phase 3/4 当前定义的管理面已迁移;后续新增 capability 也不得绕过 `TuiBackend` 或恢复旧 owner 直连。 -2. TUI 产品请求和订阅只经过 `TuiBackend`,TUI view/reducer 不执行 backend I/O。 -3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现、`product-full` 或 private IPC operation。 -4. capability、limits、身份和作用域来自真实 Host/transport,而不是通用 protocol 默认值。 -5. 事件、断线、恢复、权限、取消和 unknown outcome 有明确合同与故障测试。 +1. Phase 3/4 当前定义的管理面已迁移;Phase 5 direct-runtime 已完成;后续新增 capability 也不得绕过 backend composition 或恢复旧 owner 直连。 +2. TUI 产品请求和订阅经过 `TuiAgentClient` 的 backend composition;Runtime 行为经过 `TuiRuntimePort`,管理能力经过对应 owner service/provider,TUI view/reducer 不执行 backend I/O。 +3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现、`product-full` 或 private IPC operation;只有 CLI Host/backend composition 可以按 owner 注入已审核的 service/provider。 +4. capability、limits、身份和作用域来自真实 Host/transport,而不是通用 protocol 默认值;管理 service/provider 缺失时返回 typed unsupported。 +5. 事件、断线、恢复、权限、取消和 unknown outcome 有明确合同与故障测试;Runtime port 的每个 Shared v17 operation 都有对应覆盖证据。 6. remote workspace 不存在 controller-local fallback。 -7. 重复 DTO、无效 handler 和无生产消费方的旁路已删除。 -8. 若采用 Shared App Server,迁移满足 Phase 5 的双栈、回滚、性能、安全和删除门槛;否则文档明确 v17 是保留的私有 compatibility transport。 +7. 重复 DTO、无效 handler、70 方法单体管理 trait 和无生产消费方的旁路已删除或不再属于稳定边界。 +8. 旧 Embedded App Server 已删除且不再作为 rollback adapter;若采用 Shared App Server,迁移满足 Phase 6 的双栈、回滚、性能、安全和删除门槛;否则文档明确 v17 是保留的私有 compatibility transport。 diff --git a/package-lock.json b/package-lock.json index cbf6d89c91..6391fa184c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.2.17", + "version": "0.2.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.2.17", + "version": "0.2.18", "hasInstallScript": true, "dependencies": { "jszip": "^3.10.1", diff --git a/package.json b/package.json index 0db6d92f86..75de5e8667 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "BitFun", "private": true, - "version": "0.2.17", + "version": "0.2.18", "type": "module", "engines": { "node": ">=22.12.0" @@ -36,8 +36,11 @@ "theme:color-audit:cli": "node scripts/audit-cli-theme-colors.mjs", "theme:color-audit:all": "pnpm run theme:color-audit && pnpm run theme:color-audit:mobile && pnpm run theme:color-audit:installer && pnpm run theme:color-audit:cli", "theme:color-audit:test": "node --test scripts/audit-theme-colors.test.mjs scripts/audit-cli-theme-colors.test.mjs", + "motion:audit": "node scripts/audit-web-motion.mjs", "theme:visual-contract": "node scripts/validate-theme-visual-contract.mjs", "appearance:contract-audit": "node scripts/audit-appearance-contracts.mjs", + "flowchat:log:analyze": "node scripts/diagnostics/analyze-flowchat-log.mjs", + "flowchat:log:analyze:test": "node --test scripts/diagnostics/analyze-flowchat-log.test.mjs", "check:repo-hygiene": "node scripts/check-repo-hygiene.mjs && node scripts/update-models-dev-snapshot.mjs --check", "models-dev:check": "node scripts/update-models-dev-snapshot.mjs --check", "models-dev:update": "node scripts/update-models-dev-snapshot.mjs", @@ -46,6 +49,7 @@ "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", + "test:release-packaging": "node --test scripts/release-channel.test.mjs scripts/desktop-tauri-build.test.mjs scripts/version-generation.test.mjs scripts/tauri-release-manifest.test.mjs", "fmt:rs": "node scripts/format-changed-rust.mjs", "lint:rs": "cargo clippy --workspace --exclude bitfun-desktop --all-targets", "lint:rs:desktop": "pnpm run prepare:mobile-web && cargo clippy -p bitfun-desktop --all-targets", @@ -57,7 +61,9 @@ "build": "pnpm run build:web", "build:frontend-only": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs --skip-gen-types && pnpm run verify:monaco-assets && pnpm run prepare:mobile-web", "verify:monaco-assets": "node scripts/verify-monaco-assets.cjs", - "build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && pnpm run verify:monaco-assets", + "verify:webkit-compatibility": "node scripts/verify-webkit-compatibility.cjs", + "verify:webkit-compatibility:test": "node --test scripts/verify-webkit-compatibility.test.mjs", + "build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && pnpm run verify:monaco-assets && pnpm run verify:webkit-compatibility", "build:mobile-web": "pnpm --dir src/mobile-web build", "build:miniapp-market": "pnpm --dir src/miniapp-market-web build", "type-check:miniapp-market": "pnpm --dir src/miniapp-market-web type-check", @@ -66,6 +72,7 @@ "type-check:skin-market": "pnpm --dir src/skin-market-web type-check", "test:skin-market": "pnpm --dir src/skin-market-web test", "prepare:mobile-web": "node scripts/mobile-web-build.cjs", + "prepare:dsh-profile": "node scripts/prepare-dsh-profile.mjs", "frontend:build-all": "node scripts/frontend-build-all.mjs", "preview": "pnpm --dir src/web-ui preview", "desktop:dev": "node scripts/dev.cjs desktop", diff --git a/packages/dsh-acp/.gitignore b/packages/dsh-acp/.gitignore new file mode 100644 index 0000000000..659e82c0fb --- /dev/null +++ b/packages/dsh-acp/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +lib/ +dist-profile/ +.sessions/ +.env diff --git a/packages/dsh-acp/NOTICE.md b/packages/dsh-acp/NOTICE.md new file mode 100644 index 0000000000..9c9b4dcba2 --- /dev/null +++ b/packages/dsh-acp/NOTICE.md @@ -0,0 +1,30 @@ +# Third-party notices + +This package is a fork of parts of [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), +which is distributed under the MIT License. Copyright (c) DeepSeek. + +| File here | Upstream source | Relationship | +| --- | --- | --- | +| `src/codec.ts` | `packages/acp/acp/src/codec.ts` (`@deepseek-ai/dsh-acp`) | verbatim copy | +| `src/bridge.ts` | `packages/acp/acp/src/index.ts` (`@deepseek-ai/dsh-acp`) | session, turn, approval, and teardown machinery kept; the `session/event` dispatch replaced | +| `src/app.ts` | `packages/examples/acp-demo/src/index.ts` (`@deepseek-ai/dsh-acp-demo`) | composition kept; the transport swapped for `src/bridge.ts` | +| `src/bin.ts` | `packages/examples/acp-demo/src/bin.ts` (`@deepseek-ai/dsh-acp-demo`) | copy with a different bin name | +| `cordis.yml` | `examples/acp-agent/cordis.yml` | trimmed for an IDE launch | + +The MIT License text ships with the upstream project; this fork carries the same +terms. + +## Why fork rather than depend + +`@deepseek-ai/dsh-acp` is automation-only by design. Its `session/event` handler +publishes committed assistant text and nothing else: + +> Emit only committed assistant text. Raw chunks, reasoning, tools, plans, +> titles, and retry markers are presentation or trace data and stay off the +> automation wire. + +There is no configuration flag that changes this, so an IDE client sees no tool +cards, no reasoning, and no plan — and receives permission requests for tool +calls it was never told about. This fork exists to publish exactly that withheld +data. Upstreaming it as a verbosity level in `@deepseek-ai/dsh-acp` remains the +better long-term outcome. diff --git a/packages/dsh-acp/README.md b/packages/dsh-acp/README.md new file mode 100644 index 0000000000..6e735a9429 --- /dev/null +++ b/packages/dsh-acp/README.md @@ -0,0 +1,133 @@ +# @bitfun/dsh-acp + +An Agent Client Protocol server for [DeepSeek Harness](https://github.com/deepseek-ai) +(`dsh`), written for an IDE rather than for automation. + +The harness ships no ACP entry point of its own. The published +`@deepseek-ai/dsh-acp` is an automation surface: it deliberately withholds tool +calls, reasoning, and mode selection, because a script does not need to watch an +agent think. An IDE needs exactly those, so BitFun ships this bridge and runs it +inside the harness the user already installed. + +## For users + +Three steps, and BitFun stores none of your DeepSeek configuration: + +1. Install the harness — `npm install -g @deepseek-ai/dsh` (BitFun's agent + settings page has a one-click button that runs the same thing). It is a Node + program and needs **Node 20.12 or newer** — its own toolchain targets Node 22 + LTS. dsh declares no `engines`, so npm installs it onto an older Node without + complaint and it then fails to boot with a `SyntaxError` about + `util.parseEnv`; BitFun checks the version up front and says so instead. +2. Configure a model and an API key inside dsh, e.g. through `dsh web`'s Models + page. That writes `~/.dsh/settings.yaml` and `~/.dsh/.credentials.yaml`. +3. Pick **DeepSeek Harness** in BitFun and start a session. + +The model and the key stay where you put them. This bridge reads them through +the harness's own `dsh-settings-file`, `dsh-credentials-local`, and +`dsh-agent-default-model` services, so switching models in dsh switches them in +BitFun too. BitFun writes no DeepSeek credentials of its own. + +## How BitFun launches it + +BitFun runs `dsh --profile bitfun-acp`. A dsh profile is just a directory under +`$DSH_HOME/profiles/`, and BitFun materializes this one on first use — see +`src/crates/interfaces/acp/src/client/dsh_profile.rs`. + +``` +$DSH_HOME/profiles/bitfun-acp/ + package.json dsh.profile.bundles: [] — an empty root, so the + harness's default tool rows cannot leak into a + preset that asked to be minimal + cordis.patch.yml generated from cordis.yml; the whole file as one + `insert:` layer over that empty root + lib/** the compiled bridge + presets/** the modes the session offers + node_modules/ only what the harness closure does NOT already carry + .bitfun-bridge.json build stamp; BitFun re-syncs when it changes +``` + +Everything else resolves out of the user's own dsh: on every launch the harness +symlinks its entire dependency closure into `$DSH_HOME/profiles/node_modules`, +which ordinary Node parent-directory lookup finds from inside the profile. That +is why this package vendors almost nothing and never installs a second copy of +the harness. + +A remote workspace works the same way: BitFun packs the profile above into a tar +stream and extracts it into `$DSH_HOME/profiles/bitfun-acp/` on that host over +the session's own transport — no SFTP, so container connections are covered too +— and skips the upload when the stamp there already matches. `dsh` itself, the +models, and the key are the remote host's, exactly as they are locally. Steps 1 +and 2 above therefore have to have been done on that host — including the Node +version, which the same probe checks — and BitFun says so if they were not. + +## Reopening a conversation + +The bridge advertises ACP's `loadSession` capability, so closing a session and +clicking it again reopens the one that was there instead of quietly starting a +blank one. Without it a client has only `session/new` to fall back on, and the +reopened conversation loses its history, its context, and the mode it ran under +— it comes back under the roster default and switchable again. + +`session/load` resumes the stored session out of the harness's own persistence +(`$DSH_HOME/acp-sessions///`), replays its events to the +client as `session/update` notifications, and answers with the session's mode. +Three consequences worth knowing: + +- **The stored mode wins over the roster default.** Which preset a session ran + under is read back from its own log, so a conversation started in `minimal` + reopens in `minimal` however the default has moved since. +- **A conversation that has started comes back locked.** The mode picker shrinks + to the one mode in force, because the composition is already baked into the + transcript — the same rule a live session follows after its first turn. +- **A session belongs to the directory it was created in.** Loading it against + another `cwd` is refused rather than answered with a session whose sandbox + boundary points somewhere else. + +`node scripts/smoke.mjs --load ` drives exactly this path against a +real installation; the id is a directory name under the path above, and `--cwd` +has to name the workspace the session was created in. + +## Development + +```sh +npm ci # the pinned harness toolchain +npm run build # tsc -> lib/ +node scripts/build-profile.mjs # lib/ + presets -> dist-profile/ +node scripts/smoke.mjs # drive the profile over real ACP +npm test # vitest +``` + +`pnpm run prepare:dsh-profile` from the repository root does the install and +both build steps. Official `desktop:build` ships `dist-profile/` as a Tauri +resource; `desktop:dev` and `cargo check` do not compile it. A failure during +packaging fails the desktop build: an app that silently ships no bridge is +indistinguishable from a working one until a user starts a DeepSeek session. +`BITFUN_SKIP_DSH_PROFILE=1` opts out on purpose. + +Every `@deepseek-ai/*` dependency is pinned to the **`0.1.0-rc.6`** train — the +set npm's `next` dist-tag points at. Half of these packages still carry a +`latest` of `0.0.1-rc.1`, so an unpinned install mixes two trains and disagrees +with itself; that is worth knowing before loosening a range. They are +`devDependencies` because they are compile-time only: at runtime the profile +resolves them from the user's own dsh. + +This package is **not** a pnpm workspace member and carries its own +`package-lock.json`, so installing the harness is the desktop bundle build's +cost rather than every contributor's `pnpm install`. + +To work against a local [deepseek-harness](https://github.com/deepseek-ai) +checkout instead of the pinned versions, run `node scripts/link-local-dsh.mjs` +— it symlinks the checkout into `node_modules`, and the build then leaves that +tree alone. + +## Layout + +| Path | What it is | +| --- | --- | +| `src/app.ts` | the cordis plugin: config schema and ACP stdio transport | +| `src/bridge.ts` | session lifecycle, prompts, tool calls, permissions | +| `src/codec.ts` | harness events ⇄ ACP notifications | +| `src/tool-view.ts` | how a tool call is titled and rendered | +| `cordis.yml` | the composition; the profile patch is generated from it | +| `presets/` | the modes a session can switch between | diff --git a/packages/dsh-acp/cordis.yml b/packages/dsh-acp/cordis.yml new file mode 100644 index 0000000000..1232076c72 --- /dev/null +++ b/packages/dsh-acp/cordis.yml @@ -0,0 +1,195 @@ +# The IDE-facing ACP server composition — the HOST plane. +# +# Trimmed from deepseek-harness/examples/acp-agent/cordis.yml: the snapshot +# harness hooks, the Claude/Codex hook bridges (they need a hooks.json that has +# no meaning for an IDE launch), and the workflow/ralph/fork tools are dropped. +# +# What a session's MODEL sees — its persona, its tools, its prompt sections — +# is not here. Those rows live in ./presets//agent.cordis.yml, one +# composition per mode, and the client picks one per session through the +# `mode` session config option. A preset layer can only ADD registrations, so anything +# model-facing left in this file would appear in every mode and make the +# minimal one a lie. What stays here is what a preset must not own: the model +# route, the sandbox and approval stack, the registries the preset rows resolve, +# persistence, and the transport. +# +# Stdout carries ACP JSON-RPC, so this tree has no console logger and no HMR. +# +# WHICH model runs and WHICH key it uses are not decided here. Both come from +# the harness the user already installed and configured: `$DSH_HOME/settings.yaml` +# (what dsh's web Models page writes) and `$DSH_HOME/.credentials.yaml`, read +# through the two provider rows below. Nothing about an account is stored in +# this repository, and BitFun never asks for a key of its own. + +# User-settings document ($DSH_HOME/settings.yaml, hot-reloaded): its +# `llm-deepseek:` / `llm-pi-ai:` sections override the adapter rows below +# without a restart. Same row as the dsh-base bundle mounts. +- id: settings + name: '@deepseek-ai/dsh-settings-file' + +# Credential sources: inherited environment over the managed +# $DSH_HOME/.credentials.yaml, with project and user .env fallbacks. Adapters +# resolve references per request; nothing is materialized into this process's +# environment. A user who signed in through dsh is already signed in here. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' + +# The model a session starts on when the client names none. The values here are +# only the floor: a saved selection in the settings document wins, which is how +# "pick your model in dsh" carries into an IDE session. +- id: agent-default-model + name: '@deepseek-ai/dsh-agent-default-model' + config: + provider: deepseek-official + model: deepseek-v4-flash + +# The DeepSeek adapter: full thinking at max effort on every request, so the +# bridge has reasoning to publish as agent_thought_chunk. No key, endpoint, or +# model list is inlined — all three resolve per request from the settings +# document over this entry, with the key coming from the credential store. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + thinking: enabled + reasoningEffort: max + +# The pi-ai multi-provider twin, mounted dormant: zero routes until an +# `llm-pi-ai:` settings section supplies provider profiles, then those routes +# register live. Composed so that a user who configured a non-DeepSeek provider +# in dsh finds it here too. +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + +# Bash AND the filesystem tools are confined to the workspace; anything wider +# asks. DSH_PERMISSION_MODE overrides the default for a deployment that wants +# read-only or full access. +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js "process.env.DSH_PERMISSION_MODE ?? 'workspace-write'" + workspaceRoot: !!js process.cwd() + +# Managed child-process groups for the bash executor and the ripgrep-backed +# discovery tools. +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-sandbox' + config: + timeoutMs: 60000 + +# The managed `DSH_*` environment every model shell call inherits. Host-plane +# because it is injected — resolution happens before any session exists — and +# because both the standard and minimal shells read the one registry. +- id: shell-env + name: '@deepseek-ai/dsh-shell-env' + +# `ask` is the whole point of running over ACP rather than the SDK's JSON-RPC +# transport: each question travels to the client as session/request_permission, +# attached to a tool call the client has already been shown. +- id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "process.env.DSH_PERMISSION_MODE === 'danger-full-access' ? 'never' : 'ask'" + +# The app: agent spine + preset roster + JSONL persistence + the IDE protocol +# bridge. Loaded from source; the Loader resolves this specifier against this +# file's directory, and the roster's root against the package directory. +# +# Every spine gate below is off because the preset owns that half: the spine +# keeps its registries (tools, skills, goals, jobs, agents, loop) and hands the +# model-facing plugins to ./presets. `persona` is likewise absent — each preset +# carries its own through `@deepseek-ai/dsh-persona`. +# `provider`/`model` are deliberately absent: each session starts on +# `agent-default-model`'s live selection, so the model chosen in dsh is the +# model an IDE session runs. Sessions live under the harness home rather than +# beside whatever project happens to be open — an ACP client launches this +# adapter with the USER's workspace as the working directory. +- id: acp-agent + name: './src/app.ts' + config: + reasoning: true + persistenceRoot: !!js "process.env.DSH_ACP_SESSIONS_ROOT ?? dshHomePath('acp-sessions')" + defaultPreset: !!js "process.env.DSH_ACP_PRESET ?? 'standard'" + workspaceContext: false + toolBash: false + toolJobs: false + goals: false + skills: + enabled: false + +# Replay-aware request pressure; the routed adapter supplies model capacity. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +# Summarize an older range after measured pressure or a canonical provider +# overflow. Ratios scale against the routed model's context window. +- id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + config: + thresholdRatio: 0.8 + retainRatio: 0.08 + maxTokens: 8192 + compactionRetries: 1 + +# Projection registry backing the subagent catalog surfaces. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn-in-process + name: '@deepseek-ai/dsh-subagent-spawn-in-process' + config: + providerName: spawn + +# Host-plane, unlike the delegation TOOLS in ./presets: this registers a +# continuable setup on the subagent singleton rather than a tool an agent calls, +# and the setup list is not scope-aware — one copy per mounted preset would +# register `report` more than once and throw. +- id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + +# The skill catalog and its local-root discovery. Whether a session's model can +# CALL them is the preset's choice (`@deepseek-ai/dsh-tool-skill`). +- id: skills + name: '@deepseek-ai/dsh-skill' + +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + +# Persisted same-session goals: the service and its round driver are host-plane +# (the driver injects `agents`/`sessions`, which resolve before any session +# exists); the preset contributes `@deepseek-ai/dsh-tool-goal`. +- id: goals + name: '@deepseek-ai/dsh-goal' + +- id: goal-round-driver + name: '@deepseek-ai/dsh-goal-round-driver' + +# The TypeScript code runtime the `code` preset presents its catalog through. +# Composed here so that preset fails at mount naming its row, rather than at the +# model's first `run_code`. The worker-thread plugin IS the `codeRuntime` +# service (it extends the base class), so composing `dsh-code-runtime` beside it +# would register the service twice and fail the boot. +- id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker-thread' + +# Identical repeat calls trigger advisory context, never a block. +- id: repeat-tool-reminder + name: '@deepseek-ai/dsh-repeat-tool-reminder' + +# The filesystem stack rides the SAME sandbox policy as bash; read-before-edit +# composes orthogonally on top. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.cwd() + +- id: fs-observation-policy + name: '@deepseek-ai/dsh-fs-observation-policy' diff --git a/packages/dsh-acp/package-lock.json b/packages/dsh-acp/package-lock.json new file mode 100644 index 0000000000..b9ddaa86cb --- /dev/null +++ b/packages/dsh-acp/package-lock.json @@ -0,0 +1,5759 @@ +{ + "name": "@bitfun/dsh-acp", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bitfun/dsh-acp", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@agentclientprotocol/sdk": "0.25.1", + "@deepseek-ai/dsh-agent-spine-demo": "0.1.0-rc.6" + }, + "bin": { + "dsh-acp-ide": "lib/bin.js" + }, + "devDependencies": { + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/cordis-plugin-loader": "1.0.2", + "@deepseek-ai/dsh-agent": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-default-model": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-instructions": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop-testkit": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-presets": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-tool-presentation": "0.1.0-rc.6", + "@deepseek-ai/dsh-app-boot": "0.1.0-rc.6", + "@deepseek-ai/dsh-bash-sandbox": "0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime": "0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime-worker-thread": "0.1.0-rc.6", + "@deepseek-ai/dsh-compaction-basic": "0.1.0-rc.6", + "@deepseek-ai/dsh-credentials-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-fs-observation-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-fs-sandbox": "0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "0.1.0-rc.6", + "@deepseek-ai/dsh-goal-round-driver": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm-deepseek": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6", + "@deepseek-ai/dsh-persona": "0.1.0-rc.6", + "@deepseek-ai/dsh-repeat-tool-reminder": "0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-session": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-checkpoint-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-query-sqlite": "0.1.0-rc.6", + "@deepseek-ai/dsh-settings-file": "0.1.0-rc.6", + "@deepseek-ai/dsh-shell-env": "0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "0.1.0-rc.6", + "@deepseek-ai/dsh-skill-filesystem": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent-spawn-in-process": "0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-terminal": "0.1.0-rc.6", + "@deepseek-ai/dsh-terminal-bash": "0.1.0-rc.6", + "@deepseek-ai/dsh-token-meter": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash-persistent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-fs": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-fs-search": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-goal": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-jobs": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-str-replace-editor": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent-control": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent-report": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-todo": "0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "0.1.0-rc.6", + "@deepseek-ai/schemastery": "3.18.1", + "tsx": "4.20.3", + "typescript": "5.9.2", + "vitest": "3.2.4" + }, + "engines": { + "node": "^22.19.0 || >=24.0.0" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.25.1.tgz", + "integrity": "sha512-jx2rF3bdpGwZ75Q/meyEDLLbYmbtxk82Uh9hDCdxDvcEedBnNSF5hZAnL/kJR5VNz56JqwOmqnAqasC84MwwkQ==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz", + "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.32.tgz", + "integrity": "sha512-rlbmsMG7ZNgrVhWSqqXpq6y9hfiREyzCg3CNTk9UK+AoP7+65kOkqpWmqwLfV1UrRSHATdLnZF2rt9ZTUxYQJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.27.tgz", + "integrity": "sha512-M7Ay1VpBpf/YFfic9kkjwE3wyCh4G0gEM4RypRXYm7aPjyfqi+D8FEYMR2E3IqbvN+qi2rEFYAiwWL0XHtQYdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.50.tgz", + "integrity": "sha512-gdcWRbmIf1dWA/prf44Bnnzgqj+AbsXX2yfhZhOQLwSm7NfKIYPmkRlPqP0CTepHzjxMIBdWBDdtQB+Y/dFUeg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz", + "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.9.tgz", + "integrity": "sha512-wB/ho7pTJKqWz3WYDt2ZWDWI8bxQpN/xwf+5ZQ1zWaj+HDY9B8Fn434i6qZ6j6ZG3aCiIJtZaQVqwajx5xYsQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@deepseek-ai/cordis": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.1.tgz", + "integrity": "sha512-YBdskTU2Po1kru3GgcUWUbkTsPMA9LkSQDAY8rBkFJeajdgcQad3QPJZE26JyK99Xb6HaASvoXg2DSUTeN/0Nw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "@standard-schema/spec": "^1.1.0" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "^1.0.6", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-include": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cordis-plugin-group": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-group/-/cordis-plugin-group-1.0.1.tgz", + "integrity": "sha512-E1NThkFB3jn3TCqa6Oc++1zQHqLejF3W2wDwv2BlL3UEmgtBXL1K1j1koc+j676RuiOXtJkUJlPcOntRGKLWBQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2" + } + }, + "node_modules/@deepseek-ai/cordis-plugin-include": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-include/-/cordis-plugin-include-1.0.6.tgz", + "integrity": "sha512-i1VXrZCbv6tk/iUgedCNjrxxArbWT3IvRZGB5sdqJ3ectnihivXXQbRZ8JJ73DSmAPvlMGmrbtjFAfm10yvXRg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "js-yaml": "^4.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2" + } + }, + "node_modules/@deepseek-ai/cordis-plugin-loader": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-loader/-/cordis-plugin-loader-1.0.2.tgz", + "integrity": "sha512-RIW9hoVyhYDWdCI9BsvtZccPde1ECLC4OAxupwowGTak78vwVTVdb3HezTSOK1Y1/Ax3Ru0LA1pYOB04CnTxIQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "node-addon-require-builtin": "^0.1.4" + }, + "peerDependenciesMeta": { + "node-addon-require-builtin": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cordis-plugin-timer": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-timer/-/cordis-plugin-timer-1.1.3.tgz", + "integrity": "sha512-IOkey1VNmJwYa5U8bksyMOnM7mtirqjjbWLr3xA8QybLMK0sMooG3a6iJwtvd8P3MFwo3FQibEg+JPixp37MEw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } + }, + "node_modules/@deepseek-ai/cosmokit": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.2.tgz", + "integrity": "sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==", + "license": "MIT" + }, + "node_modules/@deepseek-ai/dsh-agent": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent/-/dsh-agent-0.1.0-rc.6.tgz", + "integrity": "sha512-vtqq2pWTrzn0dKfj5kREZRpP82AwtGjGx9V1lYnKvF+Uc/a8zyWbSvjDE7V1d3YQAQJzs2cWO31hURWDekDXIA==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-default-model": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-default-model/-/dsh-agent-default-model-0.1.0-rc.6.tgz", + "integrity": "sha512-5Fl15p5mHVMlp69Ea0qeS81ej1Bt9vSJtCed+gjvzkKzv5y3VbJQ8PPBV34F1rTSyhTtjj5Q7TuDugQI5ZpjfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-instructions": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-instructions/-/dsh-agent-instructions-0.1.0-rc.6.tgz", + "integrity": "sha512-RCd3UWJI7b3X6EUnxnqwOFiMWh1PzRRg9GrVt4G0tVNDIxeYX+RF2FTbOrslIOkOCZxG3BlI2M/bZ6yQmB4Vwg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-loop": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-loop/-/dsh-agent-loop-0.1.0-rc.6.tgz", + "integrity": "sha512-yShuKIMW360H14L4y13j3gz3Ix1s/3lwEEpfJW4hnFAE09h9Z4yJA7UfTQmQdzMRMnuj4uWFPw0e0BbapnkFuw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-loop-testkit": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-loop-testkit/-/dsh-agent-loop-testkit-0.1.0-rc.6.tgz", + "integrity": "sha512-VOjQp3OdEhiQuwruaXdMBchZ/y/8pw5sGXhKB6MmMHxsydsmnm4ew5IISwtopufx419/OomRzijkd85glpG8Vw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-presets": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-presets/-/dsh-agent-presets-0.1.0-rc.6.tgz", + "integrity": "sha512-LtDz0TYE7YNhJOhMDZ8s45xQG57bWF4F1SN3tjYDosaBdINIRTxJ0O80BC+KeliqFDw071ZNOzTUEelwEAIx7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "js-yaml": "^4.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-include": "^1.0.6", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-atomic-write": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-spine-demo": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-spine-demo/-/dsh-agent-spine-demo-0.1.0-rc.6.tgz", + "integrity": "sha512-urMS7CReaddhybJZCaPA8oI4VbD/A+62mLTOb2E4b8EKgZ55sMsOGfpkMZmFIs9nqJqxdFUmcxPXWKVMVtpb5w==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-timer": "^1.1.3", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-agent-instructions": "^0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop": "^0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-goal-round-driver": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs-local": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm-retry": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-title": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell-env": "^0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "^0.1.0-rc.6", + "@deepseek-ai/dsh-skill-filesystem": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tool-goal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tool-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tool-skill": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-tool-presentation": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-tool-presentation/-/dsh-agent-tool-presentation-0.1.0-rc.6.tgz", + "integrity": "sha512-Ysg45g0qnteB6w7krUBRXQ6SFbc8L+laPZTS2y15FFTLueruJaANBLIgnorEtj2bIJYF2Lq0fKH5v0wPNNrlXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-anonymous-user-id": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-anonymous-user-id/-/dsh-anonymous-user-id-0.1.0-rc.6.tgz", + "integrity": "sha512-+K/+tQzuDlmvKhoudB7qVMJMNF/p1+GTRQkkPA3JHIQM5yHj7AUYY8oMLROTvUivs8XWtkNyEP1/c6nPwrHazQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-app-boot": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-app-boot/-/dsh-app-boot-0.1.0-rc.6.tgz", + "integrity": "sha512-97Xb2wB0cuFMmbvW7/95pfMTyXwUNQxW/ROx19OdjS6PgHQNA28ZADZ19FgZmofguxzWuDD0lJPc1SWkSGLYcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.2.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-group": "^1.0.1", + "@deepseek-ai/cordis-plugin-hmr": "^1.0.16", + "@deepseek-ai/cordis-plugin-include": "^1.0.6", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-hmr": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-atomic-write": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-atomic-write/-/dsh-atomic-write-0.1.0-rc.6.tgz", + "integrity": "sha512-Ot/2aygDzWuN4ypUkDJA6MpSlGbcvzzKnWAms0lg+2WUtPZr7O0cGb83K3TEO2NoAu0LJfVxlbo8Eya3tDaFtw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-attachment": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.1.0-rc.6.tgz", + "integrity": "sha512-3P6N17NQ8jqSQGzeCs+svCIqArU8oq0YmgEAo+axN9aVuUDferWU4DLRSX59UGpmyldX4LQn81toA+c+DqMcHg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-bash-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-bash-local/-/dsh-bash-local-0.1.0-rc.6.tgz", + "integrity": "sha512-YAc5W9uR7sN1Rrobp6fQlCaOdHqCWatTktV68UsoDuOJs5eM3nWEUUUVN8jdZCIDZhktT5hqbhe3+DzOX3Xg1w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-bash-sandbox": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-bash-sandbox/-/dsh-bash-sandbox-0.1.0-rc.6.tgz", + "integrity": "sha512-TNLRriAjpEUrJplrI0BmZtAtntIkV5usYiKylJGPlGUfWWnJkLR0zL9gioyXW3kncHBVSnb+YTuf+7FnDVeVHQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-bash-local": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-brand": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.1.0-rc.6.tgz", + "integrity": "sha512-E8j9Nby24qP4rfrdcfc7bpt1CHpGT3tYmycOJJkEOH4ptIdT1m2ro9nmnSd5CWYukTr64A77vjm2WGqHRI92UA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-code-runtime": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-code-runtime/-/dsh-code-runtime-0.1.0-rc.6.tgz", + "integrity": "sha512-aw8D4IOeMo11A3uxQeE4LFoW3bvaQnVkGFqqtS+lsINDARrOCJHXLUgecoKpys+Lc5erZZ4UQDnymJmL4OCcKA==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-code-runtime-worker-thread": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-code-runtime-worker-thread/-/dsh-code-runtime-worker-thread-0.1.0-rc.6.tgz", + "integrity": "sha512-I2YX2ZSeQnQrSJGffM2upL+7y7vGsLwKv478gesusgYtT0kH/MOcszO9ZpnV8sIqvlkgzvWgBhXzniGHWh40ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-code-runtime": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-commands": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-commands/-/dsh-commands-0.1.0-rc.6.tgz", + "integrity": "sha512-dq1GZmGTPXEGG01ksZ6Jj6DxAkzAbg+nepvfnM/P74d6EUcsFsEy7CfS18n0VJ6/2Wp0xOWsQyyuFElqbynO7w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-compaction": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-compaction/-/dsh-compaction-0.1.0-rc.6.tgz", + "integrity": "sha512-Uu8qgrHom13gdwwxAnqmNuWM8MqafkRlhjQMMJYmaMVVxjEUewSJ6lwtMbUEBR5ViLsfv4pTm5TnhxNCjedBUw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-commands": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-compaction-basic": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-compaction-basic/-/dsh-compaction-basic-0.1.0-rc.6.tgz", + "integrity": "sha512-sqfFPBy1SqsOmaWro/GOAcDkrryjsmJLU/M+V4HZakoE7hLyGVhMElrRG5E5OlplIl2Akntc+YYwX36kDfEItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-commands": "^0.1.0-rc.6", + "@deepseek-ai/dsh-compaction": "^0.1.0-rc.6", + "@deepseek-ai/dsh-compaction-tool-result-pruner": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-token-meter": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-compaction-tool-result-pruner": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-credentials": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials/-/dsh-credentials-0.1.0-rc.6.tgz", + "integrity": "sha512-zyYRs3A9gxfZjZfONzJdMhM0Gzbslpha+FGYkLDRAozgPj0N7mZdE+Qflx2V4WNrCnsSjuvOW0HFBxWtSRUTUw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-credentials-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials-local/-/dsh-credentials-local-0.1.0-rc.6.tgz", + "integrity": "sha512-DSDcC1i8hd3Ynyr6MTPFoEdAsxcmZSLo68IPBPvaPGHZ+rCX8XDAjghWvXrNkLtkjms0ngNVPJS3rNMRcJFq0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "chokidar": "^4.0.3", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-atomic-write": "^0.1.0-rc.6", + "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-fs": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs/-/dsh-fs-0.1.0-rc.6.tgz", + "integrity": "sha512-OTkwb4QsZgmjtA/8ZEPh1FapmrBr3N989/G4Wmo1JkAvKbMxkYty6LxjckOawTTz7GJTfUoZCrw9uopDfIMMNw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-fs-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-local/-/dsh-fs-local-0.1.0-rc.6.tgz", + "integrity": "sha512-zP0OW474s7tvzEVQnBBTGL6Jrv/YyQXt4nswIbc3Aqr9JyHSfhZ/5IJoipZqfYXLS3ISWMhrF2npRg8L1NGsIQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "koffi": "^3.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-fs-observation-policy": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-observation-policy/-/dsh-fs-observation-policy-0.1.0-rc.6.tgz", + "integrity": "sha512-EiBMACBVmVEs0fDFkbVH6OwXrVDp9GrTG8c62vGBfuljZvRNv6yVwS+SDblboJrIPcp67axxHRVPOEy7z1CUKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-fs-sandbox": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-sandbox/-/dsh-fs-sandbox-0.1.0-rc.6.tgz", + "integrity": "sha512-NLUeuZkeQVNPIpUute6TW4Ts1et6XxLkQIOxunL5T9mXwizp+tcdCeNjBW6XMiG6gvpECA+IA3amFCvbVSXtXw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-fs-local": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-goal": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-goal/-/dsh-goal-0.1.0-rc.6.tgz", + "integrity": "sha512-cONQOvFqZmjtw5HSgbXJ5/8rZ2gHqdf69ONhH21cLFu5jJKVy9YbLt42SlD2sdGMv/co9nN8Kt9bWQiZ/96VbQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-goal-round-driver": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-goal-round-driver/-/dsh-goal-round-driver-0.1.0-rc.6.tgz", + "integrity": "sha512-+tpbRXZ3nq/C2btBxyFC88RU0IC0H39JY1WyL07fq3j24AWS5jZb9/05gEJPyoyaIDkV/Li4dJVCdJH4VaF9Fw==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-home-paths": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-home-paths/-/dsh-home-paths-0.1.0-rc.6.tgz", + "integrity": "sha512-gIiUBmqB3L8inFr+hvjZv2/i6EVJOHIHCHg7bBFVhcl4HDK94+8wUCXTLGy80tcuISzIQiIaLuJq/NhSmV9Amw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-invariants": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.1.0-rc.6.tgz", + "integrity": "sha512-WfEfOi99a4cpOugRAHTBSTnesLieu3ist1q9PXDXFBHX++K1rAl9+sB7YrdnbB8LH0UOY532gS9xJUYU6w0SLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } + }, + "node_modules/@deepseek-ai/dsh-jobs": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-jobs/-/dsh-jobs-0.1.0-rc.6.tgz", + "integrity": "sha512-fmyvSOVsNObmRnciuH57ZntuCSb0gflRleCeQx1ToGHRoGrR4Ndnstx33SuIXUQt6OSUhD2w2WKQTReXogpnSQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-jobs-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-jobs-local/-/dsh-jobs-local-0.1.0-rc.6.tgz", + "integrity": "sha512-vnCcHPjvXLQq2i80No61oKF/165+OvEC2iUBmVTxl6ka87yNECwYf4nrspHKqP9HbVWndtNP0lGgrnz7bo0XCw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-launch-environment": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-launch-environment/-/dsh-launch-environment-0.1.0-rc.6.tgz", + "integrity": "sha512-tTRJ1464PJUDe1Em1qq0mfdgGREzGGWo3JSqP6xeYDoX+MRXVP9/ChsZ5k6VBMjARAxw0HGBf7WR6VcupHbMZg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-llm": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.1.0-rc.6.tgz", + "integrity": "sha512-kuFGC8bHlzGTwlRxQhXjf3CYWl8M4NzH+EYIkrW8rri4iMc9W53xrdvkil5No/DUlMm8g1u7GdeiWYFy0TMvtA==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-llm-deepseek": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm-deepseek/-/dsh-llm-deepseek-0.1.0-rc.6.tgz", + "integrity": "sha512-Md9ik1e8tmrKcL2aRpif8qdndl9TObbfHodj6CeVlkrL9y+PEghVPZqOYtnuRcv4/e0nVSIJsUw2gFvaL+T40Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "eventsource-parser": "^3.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.6", + "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-llm-pi-ai": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm-pi-ai/-/dsh-llm-pi-ai-0.1.0-rc.6.tgz", + "integrity": "sha512-5RvzkpVCYLg9A3IGdm04px7XOaF/xikuMLe2toBY4A0qtJraXiZtUN1QBOL9i6u7DTOLG9oHP/USsbWRpyI+1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "@earendil-works/pi-ai": "^0.82.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-llm-retry": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm-retry/-/dsh-llm-retry-0.1.0-rc.6.tgz", + "integrity": "sha512-HdjBWcQ6spwA8B5dScRhndLQNiKoQTkq36O+kmJ80B9V4z08K5XrJwAInH9cCxJ91CqNzx/sxCdDds0oZDH1Pw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-output-retention": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-output-retention/-/dsh-output-retention-0.1.0-rc.6.tgz", + "integrity": "sha512-3TQJxGqjotWrvNKI+Da8nIDjzpKZEGpImeMBp7EklfO0CDbiXA5B0Ms39zBniWkWr1lvbm2iJiYERY5uDeP6og==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-persona": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-persona/-/dsh-persona-0.1.0-rc.6.tgz", + "integrity": "sha512-rY5AVVopkS9vJl+hGHGQP0nSwzxawubRvEKo4JZmGK7bKZlvevRvj8bd2wLNhTx54jppz+u0iEUq/fqG5k9Mvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-repeat-tool-reminder": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-repeat-tool-reminder/-/dsh-repeat-tool-reminder-0.1.0-rc.6.tgz", + "integrity": "sha512-iN9FjWXL8e/IrxTFmybTKDqtB6obV5jt6ao2I3EQWOhQ0GVfXBuz1SY6Yx7gILQXWsCUeGRu1ltLyywdubpJcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox/-/dsh-sandbox-0.1.0-rc.6.tgz", + "integrity": "sha512-SLZjuivQQKHTx4H7xlsjraEGGMIEA8BYOwvlm/9uZBrBrlttMU+d8Ne6QYpDcOwIEubL8mvrWRg8j3rtY3lVyA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-local/-/dsh-sandbox-local-0.1.0-rc.6.tgz", + "integrity": "sha512-W0CehRbWqAaHAYFw24wvYLlGxxJr2OlQQlFH/p1QbeNKtHzZ+6pgjksyFotgHdzOycNnVoBjb92RbQPfJhJZ9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-sandbox-windows-acl": "^0.1.0-rc.6", + "@deepseek-ai/node-addon-landlock-run": "^0.1.1", + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-policy": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-policy/-/dsh-sandbox-policy-0.1.0-rc.6.tgz", + "integrity": "sha512-XEB7+pJZVPWmZXv27EX/4V/1noVLUF5ZHyy3EY5tC4XtEyayWKtvHL4y9OTLIYhlU4Ualv5ag8s5ueGGq3ID8Q==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-windows-acl": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-windows-acl/-/dsh-sandbox-windows-acl-0.1.0-rc.6.tgz", + "integrity": "sha512-/L1TUOQMsJe8B2v1pJpTaLwkvMQIwrjlUg4+6yx2flU7AyycjFzlKwmzQQzo19/evkiqO88mLgod3FiLhqxEvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "koffi": "^3.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-scope": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-scope/-/dsh-scope-0.1.0-rc.6.tgz", + "integrity": "sha512-UlDLV4syLoJinNg9imhXrSAHrdaTa5Ff8gg46rzjFJGPUOhAk3DZff0hryT5OhrBi0A5Tj92qVpg2pRVvxnUzQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.1.0-rc.6.tgz", + "integrity": "sha512-8tu8I6VWC7050GAUXWhcEWQw4pakALQc8TlhKr52m7Y4+kIKeNt3FBgP86PaGPBtpK0p5zUPRQNkFpzZbBdxyw==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-checkpoint-policy": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-checkpoint-policy/-/dsh-session-checkpoint-policy-0.1.0-rc.6.tgz", + "integrity": "sha512-wdWNyS/95wI3QB9SHeeeQ9HDIWeFQKInI54Wqlw35H+bGGjGZE92nx9QiqiiosoxHedQFBdA0TKgON4EIoxCgg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-persistence": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-persistence/-/dsh-session-persistence-0.1.0-rc.6.tgz", + "integrity": "sha512-AbNBe+IYCbZqSHqOACVdj8QTynm2HZ0cThrEuI6nGMtlWLYLx6lzZ1rgO/56Av9mIScjyTBGJAIKhEYaMTBG9g==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-persistence-jsonl": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-persistence-jsonl/-/dsh-session-persistence-jsonl-0.1.0-rc.6.tgz", + "integrity": "sha512-US9Q4b5CZJPRRqa/M+WESL5VAOLjfYWjdzb3TQ/7zmpR4/+EQWCOFA9CtDSMh4t1oFOQBjz2+YJJcGu59MKHDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "koffi": "^3.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-projection": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-projection/-/dsh-session-projection-0.1.0-rc.6.tgz", + "integrity": "sha512-DYLALBPdEI1LZjJ4B6rdGdGY4gy+iR2+5Xh2xJoAGa/pTyN5Z55TNfvuMJrmeRBjAuDXNUQpmURbvos5rJ4veg==", + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-query": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-query/-/dsh-session-query-0.1.0-rc.6.tgz", + "integrity": "sha512-bGzRrZnWuXDUtAmwkRxT74Nuc4PLQJmJ4IeGgSSTIGJ9JQCNbvVjr4ZWDWg+D/PvbZwh9mpYJspDZdUP42eWAA==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-title": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-session-query-sqlite": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-query-sqlite/-/dsh-session-query-sqlite-0.1.0-rc.6.tgz", + "integrity": "sha512-sbeOPAAJetQbvgyFB7zVANSlJPjNgEGxiixin/DI9TcXHcUqq3nrPgS+1iI+WJjD6/zhHdIticeEayuWXtORdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-query": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-session-title": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-title/-/dsh-session-title-0.1.0-rc.6.tgz", + "integrity": "sha512-RHV1ujuomvQB63HPI/n25c2/2It5XQ7zNQKozSqYOPQw7AIr16PT9iFqC7sCQ4l2gAY5GI3FbI1cQgHzjHNOag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-settings": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings/-/dsh-settings-0.1.0-rc.6.tgz", + "integrity": "sha512-5ZlH2FNRU0kkdcCoEJohvdb2fiNnxM+iZqN3icbR3WQbMm8RPRrXlZlps5YUaQKPRbNRHA2LkOXW998QRlhHcA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/schemastery": "^3.18.1" + } + }, + "node_modules/@deepseek-ai/dsh-settings-file": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings-file/-/dsh-settings-file-0.1.0-rc.6.tgz", + "integrity": "sha512-OgwvP/dTlrWd8MRhjJbeolMJHDdyjLtvd+gS3z2mvCGDmNkkf0RxcB/8o2uoqe97iuklTWmy6cCws6dlmnDL+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "chokidar": "^4.0.3", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-atomic-write": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-shell": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-shell/-/dsh-shell-0.1.0-rc.6.tgz", + "integrity": "sha512-l/pcwbdpM+Zgp2AzH0kH0z7bPU7L3k4uG1t5Du1V5ilk70tyMSxz2upNfAbjkDHZZZO18LTs33aq+zRWHX1CUg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-shell-env": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-shell-env/-/dsh-shell-env-0.1.0-rc.6.tgz", + "integrity": "sha512-znxHFSqduw8U/AxKSO8SgSt703Bl+irRzTU0ukJT+ySlnyAwSAg8sZHLo5ZlEt5K8BdWhxz8Vc5jibOtJPB3ZQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-skill": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill/-/dsh-skill-0.1.0-rc.6.tgz", + "integrity": "sha512-VyRlCOASIRuTflKwn4NvbdAXxSPnnLJ+RAQUI4GK5pOtPwI2DhJrbovqpyp5kNAkZMsqvIeHWu5PcCEJ+FpQ2w==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-skill-filesystem": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill-filesystem/-/dsh-skill-filesystem-0.1.0-rc.6.tgz", + "integrity": "sha512-NCgz8lCAdvd0oSGIF0EKg1OXfIC7x9r0an7mgC19F0DGzYZJe5ve6AwEYpSvKITE3V8W4NbUzLKyyZG3VH5QtQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "chokidar": "^5.0.0", + "yaml": "^2.4.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-skill-filesystem/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@deepseek-ai/dsh-skill-filesystem/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@deepseek-ai/dsh-spill": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-spill/-/dsh-spill-0.1.0-rc.6.tgz", + "integrity": "sha512-reV4utwd3E5LgiNrsaqh6i6zxHboVyrrL1ilALRukDbMxSyup1vuSRh+4CPxSUh9YcoToFJ6jz2Zmlsw755g1w==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-subagent": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subagent/-/dsh-subagent-0.1.0-rc.6.tgz", + "integrity": "sha512-vROmBDAlaFAzzSlTBOlvg/7fO55zxhUztnLtB3lKmN5RevrNQBjTsbeIMDQ8ow5ZplxEOnLU+sikFoA5JaoH8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection-cache": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + }, + "@deepseek-ai/dsh-jobs": { + "optional": true + }, + "@deepseek-ai/dsh-sandbox": { + "optional": true + }, + "@deepseek-ai/dsh-sandbox-policy": { + "optional": true + }, + "@deepseek-ai/dsh-session-persistence": { + "optional": true + }, + "@deepseek-ai/dsh-session-projection": { + "optional": true + }, + "@deepseek-ai/dsh-session-projection-cache": { + "optional": true + }, + "@deepseek-ai/dsh-user-approval": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-subagent-in-process-driver": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subagent-in-process-driver/-/dsh-subagent-in-process-driver-0.1.0-rc.6.tgz", + "integrity": "sha512-KK8Ep4PeSyrM3YQdcZhetXsiBd+bliKu6NWWKyTh60LobKtVNREfPBlqRfzc0mU0ZIKD4VEC7RfuJaqvz4UlPw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-subagent-spawn-in-process": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subagent-spawn-in-process/-/dsh-subagent-spawn-in-process-0.1.0-rc.6.tgz", + "integrity": "sha512-62mtUEr5megxVy6CwQCdZVq5MCSt+kMw74ns5m7PK0PlZTWcxQVBQHNdfkE9sX4Cageu7YTvciUJuH8amm6bsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent-in-process-driver": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-subprocess": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess/-/dsh-subprocess-0.1.0-rc.6.tgz", + "integrity": "sha512-nZaZRjSnE1he+GAd14vURAH7n3Fw5eGx3nzMbkB96Y6Qx+oQB/7PTuXxKVxlCh8TSfMJTf851ahwWb1eALjKlw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-subprocess-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess-local/-/dsh-subprocess-local-0.1.0-rc.6.tgz", + "integrity": "sha512-D2daTRaprE25ti1Ra69eURPwLULpnRyUHqAGtcMHddcjNIsZ4yR0DGinYOsgoRymzu3t02qLxyoio05atSViRw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-pty": "^1.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-system-prompt": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.1.0-rc.6.tgz", + "integrity": "sha512-E7g+XChh4q4/wX++v56z1pV4SA1Rtz42xkznLPPi9FlXrrzJxwHMOUzBZ9Rz3Y1kLhQ++HJG3ZatmNx3rjFilg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-terminal": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-terminal/-/dsh-terminal-0.1.0-rc.6.tgz", + "integrity": "sha512-riHLAQhXJJ2TT9XrDWGMgJf21RBqQgSZdEVvG4+xGEf9NZWcoszxdp+KW7rpwfrKuuD70p8RHCPGW4gCQPA+/A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-terminal-bash": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-terminal-bash/-/dsh-terminal-bash-0.1.0-rc.6.tgz", + "integrity": "sha512-KRR/b/qqKO+sf6yc0xPC64LCZ+SQgTAnEMSO7gNXQ4BeAIzUbeKYfeVYiG6XZRbQ6al6o5sWIbZ0lUt3pHGdXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6", + "@deepseek-ai/dsh-terminal": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-timeout": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.1.0-rc.6.tgz", + "integrity": "sha512-CUean0fAnfsJVszFEip7PsU/S26W+JfDFfsza2dCtlw8n6xlkbHA9Gjxdk2aTwqDGCgXEPkRW7mYkdJ0n6FR7w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-token-meter": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-token-meter/-/dsh-token-meter-0.1.0-rc.6.tgz", + "integrity": "sha512-qU5FT4n1RJXP3Ss8NJ5TTvPo8rZ6cxcrCedp1t0sTAj93SGssAanq47voSmznPP52nwz/SLeunznAIT0jhbsZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-compaction": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-bash": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-bash/-/dsh-tool-bash-0.1.0-rc.6.tgz", + "integrity": "sha512-yzo/xPCObiFe+OU0e8E5nVOGpW6mp9vx/3q+MHQ0aqEbqrqHUfVMGPobfDAd/I1Ja/9Nag5NIvHpI2Hwx7lACg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell-env": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-bash-persistent": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-bash-persistent/-/dsh-tool-bash-persistent-0.1.0-rc.6.tgz", + "integrity": "sha512-bsM1RHwkpNRAy0zufIDNUWxeiuGngc+R35RZ20gbWM878rHOZVuicy4MG8NKhzqGLqMD4NuDoNfjtDUkZHC48A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-terminal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-fs": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-fs/-/dsh-tool-fs-0.1.0-rc.6.tgz", + "integrity": "sha512-bJfo8cCacX3WBygG8GbIlGz3jQ15MyIv4htGCjKpIIRHrXHSAelNtzceO2xQ21/9BkXrvg+i50mqySOcKQ0b3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "diff": "^9.0.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-fs-search": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-fs-search/-/dsh-tool-fs-search-0.1.0-rc.6.tgz", + "integrity": "sha512-MUu9QF2Du8F6zdFP5rPEti9LKr/IyCYLM24XbaeuZZCrbq6Q1Rf9sng3v5bD9TlT+Wm+uXv3AqPX+8r8gcdqIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "@vscode/ripgrep": "^1.18.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-output-retention": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-spill": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-goal": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-goal/-/dsh-tool-goal-0.1.0-rc.6.tgz", + "integrity": "sha512-5rYb1wWJa2HfCxi1JNMkPJwN0upoblJEVlnV+omTx4ZDGekg2dyfftaQdFH9cwiW576is8bAUgPE9Rd0sZCAzw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-jobs": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-jobs/-/dsh-tool-jobs-0.1.0-rc.6.tgz", + "integrity": "sha512-mcftmyHx2bq8u5Qwo7znysa3vOKO86ccJvgEZqfDV7S+UhOGbPDue7psyx1/jD/5fbK+653JbxQ8ds9wsLkiPw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-output-retention": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-skill": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-skill/-/dsh-tool-skill-0.1.0-rc.6.tgz", + "integrity": "sha512-AebSfHSt6j0PXuN3yK9+X4aC6tHSnFdm2hZglII7aEHr/lL9UbynPb3NTumh6thXZ9Earvwhf9JdOxHMsjHqfw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-str-replace-editor": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-str-replace-editor/-/dsh-tool-str-replace-editor-0.1.0-rc.6.tgz", + "integrity": "sha512-Kv+zA9ZQCn85VLfox2WVvNI/iU6e3ml4gPLqXiKMC027/iwmGHTcRMKOlDNnwydFTJOucNs8wW4Ac3FafCn3gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-subagent": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-subagent/-/dsh-tool-subagent-0.1.0-rc.6.tgz", + "integrity": "sha512-yK+ETLxDZhtwc19uqv5yAVzq3x/i2hjV9p67Or1a+s9Eqs+eu+ZJJk5hQ/2JeRiJet2otOC4zzOkYb/UVSBFyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-subagent-control": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-subagent-control/-/dsh-tool-subagent-control-0.1.0-rc.6.tgz", + "integrity": "sha512-zoVt+uV8yyg1dAuIMydtz/AfARZPkjFOXQLd90a3Bj8GdxpE3tdaR6ZIv3xD39Grxe2vRz4gOZEJ9qth3O+s9g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-subagent-report": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-subagent-report/-/dsh-tool-subagent-report-0.1.0-rc.6.tgz", + "integrity": "sha512-DqnJ6yJ0wO35jdcpCSuogbx/PC4xE8wrcVxXPvSw9I59p/8nROsfwB9UXuJoilp/wenREUoTi0X8+5ZOVSRLqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-todo": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-todo/-/dsh-tool-todo-0.1.0-rc.6.tgz", + "integrity": "sha512-ll9xmgkrV6nsxjEZn5+YkNwQei7WrMMisnbr9mAKmqN5vpyOVHXisLvKdiASEmgpm4rsicnR8Y0wcBUfbeJTxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tools": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.1.0-rc.6.tgz", + "integrity": "sha512-Tu08EPK3JyK0iNjH4FGzu/1uADynNSS6SmwOLdfytUN0YNqwNuKFSt2OJUg19famNlTgy992DcHfDu0T+gLXFg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-typert-protocol": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-protocol/-/dsh-typert-protocol-0.1.0-rc.6.tgz", + "integrity": "sha512-weWzN8r01YCkoDCAM7BsKw2YhRrD4zL8N2SAZu9hovYtXSq8xHXsP4Zh8RLYIlYcuotjyff/6hic+0TJPd14YA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-user-approval": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-user-approval/-/dsh-user-approval-0.1.0-rc.6.tgz", + "integrity": "sha512-9rnkSDGOpu2XUeGwbPeTzVUTFWTND1PMPM5L/ZQPptV5yyZlQiNxM2rCC6OdL+ZVerwxEqrRhZIQn/KVtQfKag==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/node-addon-landlock-run": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/node-addon-landlock-run/-/node-addon-landlock-run-0.1.1.tgz", + "integrity": "sha512-aHGhlQJEutfobKM/4K59SERbT7RmQdD2oMKzD8Bne/Ps7TeT8AweCN+dpdfuxQhMNbFcJMymrgPnID0WYQ30Tw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@deepseek-ai/node-addon-landlock-run-linux-arm64": "0.1.1", + "@deepseek-ai/node-addon-landlock-run-linux-x64": "0.1.1" + } + }, + "node_modules/@deepseek-ai/node-addon-landlock-run-linux-arm64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/node-addon-landlock-run-linux-arm64/-/node-addon-landlock-run-linux-arm64-0.1.1.tgz", + "integrity": "sha512-lYY2RbcPW4rGRM5hVJbrXlvLqyBxeJBjBqvt+QTHTU+GtfUVVjTODKa4e3CRwMQoCEpOjoARdQBHbN7HvE72WQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@deepseek-ai/node-addon-landlock-run-linux-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/node-addon-landlock-run-linux-x64/-/node-addon-landlock-run-linux-x64-0.1.1.tgz", + "integrity": "sha512-OHAzPW2Coe/iYobAJAAA8CeVrBoKV4BnNHsgwvXwOfishxkUVSWSvdyxrZPiwYRXutpIGVrSo9zV3WOQy2euBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@deepseek-ai/schemastery": { + "version": "3.18.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.1.tgz", + "integrity": "sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "@standard-schema/spec": "^1.1.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz", + "integrity": "sha512-3WFYRhEp3lQB3444EhPMBcM7zSaEUE3eJgHOR7s4081NLqbw/FsWilIKWXSua0Gv3sRr7m9xMidR3pPDE7jI/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.5.tgz", + "integrity": "sha512-IpqITl2fJi3QN9bTtNnygWPdK7ScSjw3xtGu8e6feYGvimCysu+spgI5KyeslY2jTnqxGS9xr8pLAbLhGJ8edA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.5.tgz", + "integrity": "sha512-4Tia4BS5EV/+vN9eIrdToanVe+U/2VqTZCBgOzoUbPKjgky51eqM+3J4qdRUvmYJohcJNPob5/hsxeItUZrl1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.5.tgz", + "integrity": "sha512-bP94uzseFO79NG3flpU3WxfyvltD+jzC/kN8FDZLi8J0VUZNW1Wmu8yq87KEzjX1qT9KyX4Y+elVbGqHXHTv2Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.5.tgz", + "integrity": "sha512-raFXXAPHzvCQWhaoMUF+Cc2ZWgg2UBU0RVoowHZhaw9nQYPC1pERcPRH+JA+SNIN6g4d2GFW6uPFc+QbUhsagA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.5.tgz", + "integrity": "sha512-h6RyBZmPMBIDWTABkJIhzDdYwSnYAJvTacHpEjbT55Arkmw1H15Rl7CFtXuEuBrqh+uivoCrRgA6vszl9CsJ9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.5.tgz", + "integrity": "sha512-u0vCmKPu4yQDhl/ri1J6U3vDnvYtYjoZaIWb+oMbRXhVZeiqdE53MGPb+q2A7Dj2n9IbYloAfEICQbL6l0pmiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.5.tgz", + "integrity": "sha512-Xa5JbumWglwPVZgrJcLhqyC1wCWlfm7+C00p3FuOTNGp0qoYuf2/tOoAh0/q7+taVm30cMopu/6lRHwdmF6I/w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.5.tgz", + "integrity": "sha512-F3i2CeTcqVBUQiSRUBSEzX1VgtXmLLiZb/ouZtXHkWpTLhJNd9TH7s3CizTofci0VRqlKdexGUYhK5vzPDAAHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.5.tgz", + "integrity": "sha512-2TgQuzy+4PfDg+rw3kOmN6lywEWdzKT3eaLPbOp0b/9DaN7CLBJ/QIR5GhGsMNpeI30zU0YzFeYFxoVoJ/LqFw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.5.tgz", + "integrity": "sha512-2yaIg/1V0m4CiAUMzG4CIlWmq1WJ+QBMlFfaGr9su+OH5fuIqC7V3BbMiB03IwIl1VofIZO5JA4Db4lID8tpbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.5.tgz", + "integrity": "sha512-8/OXd+u9omMooykhvdJPEP7u6FFzzrrFo9gOmSHc9/DPt3XkVYOtSsE97PDk6zYaAzwIYHSKjHvIXsfFwFc7sg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.5.tgz", + "integrity": "sha512-SpeqldKkuDk2aTj5PVWumy7eq6Tr2GtBPAOI1NiDHhg8xe433KraxrA9V9UjEd+1+kSGJQGR04nQByN3MPA9PQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.5.tgz", + "integrity": "sha512-uej3YAEKAhlfVPoIo5sOwtxhTLRVJ01LgtWrKGpnnAQU3C+Ilmaxdh+Oc2xc1G3NK30N5eCVJpyO9r3pjKC6Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.5.tgz", + "integrity": "sha512-d42jv2f4PwtJGNJS19Xfn/BRtGsBNNVkw0O0K5tkIGI+yNq4MnPTSUsaGbDIkCLNsCgk/LFqAaE8BExyCdrujA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.5.tgz", + "integrity": "sha512-Pyo1WEHEP6Ek2NEn2pquwJzSPLOdY4vymoPSz0an1DgvFWSkOyBYYkGVoxL1ajfj5I1pPdXysYqixtVTzAtOfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@smithy/core": { + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.0.tgz", + "integrity": "sha512-uKbkxgqLyepQDZoq8aRSdUqD1ID//rOqG96ixBhp++O7vBtmwYM6fwldGhr9HJP0iYrdc7GP/AlgzPWEZIrNRg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vscode/ripgrep": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz", + "integrity": "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@vscode/ripgrep-darwin-arm64": "1.18.0", + "@vscode/ripgrep-darwin-x64": "1.18.0", + "@vscode/ripgrep-linux-arm": "1.18.0", + "@vscode/ripgrep-linux-arm64": "1.18.0", + "@vscode/ripgrep-linux-ia32": "1.18.0", + "@vscode/ripgrep-linux-ppc64": "1.18.0", + "@vscode/ripgrep-linux-riscv64": "1.18.0", + "@vscode/ripgrep-linux-s390x": "1.18.0", + "@vscode/ripgrep-linux-x64": "1.18.0", + "@vscode/ripgrep-win32-arm64": "1.18.0", + "@vscode/ripgrep-win32-ia32": "1.18.0", + "@vscode/ripgrep-win32-x64": "1.18.0" + } + }, + "node_modules/@vscode/ripgrep-darwin-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-arm64/-/ripgrep-darwin-arm64-1.18.0.tgz", + "integrity": "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-darwin-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-x64/-/ripgrep-darwin-x64-1.18.0.tgz", + "integrity": "sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm/-/ripgrep-linux-arm-1.18.0.tgz", + "integrity": "sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm64/-/ripgrep-linux-arm64-1.18.0.tgz", + "integrity": "sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ia32/-/ripgrep-linux-ia32-1.18.0.tgz", + "integrity": "sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ppc64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ppc64/-/ripgrep-linux-ppc64-1.18.0.tgz", + "integrity": "sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-riscv64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-riscv64/-/ripgrep-linux-riscv64-1.18.0.tgz", + "integrity": "sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-s390x": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-s390x/-/ripgrep-linux-s390x-1.18.0.tgz", + "integrity": "sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-x64/-/ripgrep-linux-x64-1.18.0.tgz", + "integrity": "sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-win32-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-arm64/-/ripgrep-win32-arm64-1.18.0.tgz", + "integrity": "sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-ia32/-/ripgrep-win32-ia32-1.18.0.tgz", + "integrity": "sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-x64/-/ripgrep-win32-x64-1.18.0.tgz", + "integrity": "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.2.tgz", + "integrity": "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/koffi": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.5.tgz", + "integrity": "sha512-XVwwrxg0Ca6IEUQF4YtGIU4XN0LSselFYpYvgfhh8wafCunhEEx5hPr7LZhp5QyeFA/LcRsKHTqncCdjWjWAlg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.5", + "@koromix/koffi-darwin-x64": "3.1.5", + "@koromix/koffi-freebsd-arm64": "3.1.5", + "@koromix/koffi-freebsd-ia32": "3.1.5", + "@koromix/koffi-freebsd-x64": "3.1.5", + "@koromix/koffi-linux-arm64": "3.1.5", + "@koromix/koffi-linux-ia32": "3.1.5", + "@koromix/koffi-linux-loong64": "3.1.5", + "@koromix/koffi-linux-riscv64": "3.1.5", + "@koromix/koffi-linux-x64": "3.1.5", + "@koromix/koffi-openbsd-ia32": "3.1.5", + "@koromix/koffi-openbsd-x64": "3.1.5", + "@koromix/koffi-win32-arm64": "3.1.5", + "@koromix/koffi-win32-ia32": "3.1.5", + "@koromix/koffi-win32-x64": "3.1.5" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/dsh-acp/package.json b/packages/dsh-acp/package.json new file mode 100644 index 0000000000..a8e3ecc576 --- /dev/null +++ b/packages/dsh-acp/package.json @@ -0,0 +1,116 @@ +{ + "name": "@bitfun/dsh-acp", + "version": "0.0.1", + "private": true, + "description": "IDE-oriented Agent Client Protocol server for DeepSeek Harness: publishes tool calls, reasoning, and plans that the automation-only @deepseek-ai/dsh-acp deliberately withholds", + "license": "MIT", + "type": "module", + "main": "lib/app.js", + "types": "lib/types/app.d.ts", + "bin": { + "dsh-acp-ide": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/app.d.ts", + "default": "./lib/app.js" + }, + "./bridge": { + "types": "./lib/types/bridge.d.ts", + "default": "./lib/bridge.js" + }, + "./bin": { + "types": "./lib/types/bin.d.ts", + "default": "./lib/bin.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/**/*.js", + "lib/types/**/*.d.ts", + "cordis.yml", + "presets/**/*.yml" + ], + "engines": { + "node": "^22.19.0 || >=24.0.0" + }, + "scripts": { + "link:dsh": "node scripts/link-local-dsh.mjs", + "build": "tsc -p tsconfig.build.json", + "start": "node --import tsx src/bin.ts --config ./cordis.yml", + "test": "vitest run" + }, + "dependencies": { + "@agentclientprotocol/sdk": "0.25.1", + "@deepseek-ai/dsh-agent-spine-demo": "0.1.0-rc.6" + }, + "comment:dependencies": [ + "Every @deepseek-ai/* below is pinned to the 0.1.0-rc.6 train, the set npm dist-tag `next` points at.", + "(`latest` still points at 0.0.1-rc.1 for half of them, which is why a mixed install disagrees with itself.)", + "devDependencies are compile-time only: at runtime the profile resolves them out of the user own dsh", + "installation, which symlinks its whole closure into $DSH_HOME/profiles/node_modules on every launch.", + "This package is deliberately NOT a pnpm workspace member and carries its own package-lock.json, so that", + "installing the harness is the desktop bundle build cost alone rather than every contributor install." + ], + "devDependencies": { + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/cordis-plugin-loader": "1.0.2", + "@deepseek-ai/dsh-agent": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-default-model": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-instructions": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop-testkit": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-presets": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-tool-presentation": "0.1.0-rc.6", + "@deepseek-ai/dsh-app-boot": "0.1.0-rc.6", + "@deepseek-ai/dsh-bash-sandbox": "0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime": "0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime-worker-thread": "0.1.0-rc.6", + "@deepseek-ai/dsh-compaction-basic": "0.1.0-rc.6", + "@deepseek-ai/dsh-credentials-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-fs-observation-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-fs-sandbox": "0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "0.1.0-rc.6", + "@deepseek-ai/dsh-goal-round-driver": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm-deepseek": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6", + "@deepseek-ai/dsh-persona": "0.1.0-rc.6", + "@deepseek-ai/dsh-repeat-tool-reminder": "0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-session": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-checkpoint-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-query-sqlite": "0.1.0-rc.6", + "@deepseek-ai/dsh-settings-file": "0.1.0-rc.6", + "@deepseek-ai/dsh-shell-env": "0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "0.1.0-rc.6", + "@deepseek-ai/dsh-skill-filesystem": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent-spawn-in-process": "0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-terminal": "0.1.0-rc.6", + "@deepseek-ai/dsh-terminal-bash": "0.1.0-rc.6", + "@deepseek-ai/dsh-token-meter": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash-persistent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-fs": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-fs-search": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-goal": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-jobs": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-str-replace-editor": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent-control": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent-report": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-todo": "0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "0.1.0-rc.6", + "@deepseek-ai/schemastery": "3.18.1", + "tsx": "4.20.3", + "typescript": "5.9.2", + "vitest": "3.2.4" + } +} diff --git a/packages/dsh-acp/presets/code/agent.cordis.yml b/packages/dsh-acp/presets/code/agent.cordis.yml new file mode 100644 index 0000000000..85175b7ce1 --- /dev/null +++ b/packages/dsh-acp/presets/code/agent.cordis.yml @@ -0,0 +1,95 @@ +# The `code` agent preset: the standard coding agent, presented as Code Mode. +# +# Adapted from deepseek-harness/apps/cli/config/agent-presets/code (MIT, +# Copyright (c) DeepSeek — see ../../NOTICE.md). +# +# Everything in `standard` is here unchanged. What is added is the +# `tool-presentation` row: instead of one tool call per action, the model writes +# a TypeScript program against a generated SDK and `run_code` executes it, so a +# sequence that would be five round trips becomes one. +# +# The tool registry itself stays on the host plane — the agent loop's scheduler +# is its consumer — so what this preset owns is the PRESENTATION of that +# registry for agents joined to it. A `standard` session runs beside a `code` +# one in the same process, each seeing its own catalog. +# +# Note for IDE clients: in this mode the client sees `run_code` calls rather +# than one card per file read or shell command. That is the trade this preset +# makes, and it is why it is not the default. + +# ── identity ──────────────────────────────────────────────────────────────── + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: |- + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: true + +# ── background jobs ───────────────────────────────────────────────────────── + +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + +# ── skills and goals ──────────────────────────────────────────────────────── + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── delegation ────────────────────────────────────────────────────────────── + +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + +- id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + maxDepth: 1 + +# ── planning ──────────────────────────────────────────────────────────────── + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# ── presentation ──────────────────────────────────────────────────────────── + +# Code Mode for agents on this preset. The row waits for the host's +# `codeRuntime` rather than assuming it: a deployment that composes no +# TypeScript runtime fails this preset at mount, naming this id, instead of at +# the first request. +- id: tool-presentation + name: '@deepseek-ai/dsh-agent-tool-presentation' + config: + mode: code diff --git a/packages/dsh-acp/presets/code/preset.yml b/packages/dsh-acp/presets/code/preset.yml new file mode 100644 index 0000000000..fc0836f479 --- /dev/null +++ b/packages/dsh-acp/presets/code/preset.yml @@ -0,0 +1,3 @@ +name: PTC 模式 +description: 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 +order: 2 diff --git a/packages/dsh-acp/presets/minimal/agent.cordis.yml b/packages/dsh-acp/presets/minimal/agent.cordis.yml new file mode 100644 index 0000000000..2967d1e3f5 --- /dev/null +++ b/packages/dsh-acp/presets/minimal/agent.cordis.yml @@ -0,0 +1,58 @@ +# The `minimal` agent preset: a fixed-prompt, two-tool coding-agent composition. +# +# Adapted from deepseek-harness/apps/cli/config/agent-presets/minimal (MIT, +# Copyright (c) DeepSeek — see ../../NOTICE.md). One deliberate change: the +# upstream preset shadows the host filesystem with a bare `dsh-fs-local`, which +# would put the editor outside the sandbox this deployment answers permission +# requests from. Here the editor resolves the HOST's sandboxed `fs`, so a write +# beyond the workspace still reaches the ACP client as a permission request. +# +# The persona is the complete system prompt, so global identity, tool guidance, +# and later assembly listeners cannot add prompt text. Runtime context snapshots +# are suppressed, and the model composes only persistent `bash` and +# `str_replace_editor`. Context compaction still runs — it is host-plane here. + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: You are a helpful software engineer assistant. + complete: true + includeRuntimeContext: false + +# The PTY registry is an agent-owned service, so it lives in an entry-local +# realm. The backend still consumes the host sandbox policy and subprocess +# implementation, while the tool registers into the host tool catalog. +- id: persistent-shell + name: cordis:group + group: true + isolate: + terminals: true + config: + - id: pty + name: '@deepseek-ai/dsh-terminal' + + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +# Registers into the host `tools` registry and provides nothing, so it needs no +# realm. It requires absolute paths and reads the host's sandboxed `fs`. +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/packages/dsh-acp/presets/minimal/preset.yml b/packages/dsh-acp/presets/minimal/preset.yml new file mode 100644 index 0000000000..026cee1181 --- /dev/null +++ b/packages/dsh-acp/presets/minimal/preset.yml @@ -0,0 +1,3 @@ +name: 极简模式 +description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent,系统提示固定且不注入运行时上下文。 +order: 3 diff --git a/packages/dsh-acp/presets/standard/agent.cordis.yml b/packages/dsh-acp/presets/standard/agent.cordis.yml new file mode 100644 index 0000000000..74f90259db --- /dev/null +++ b/packages/dsh-acp/presets/standard/agent.cordis.yml @@ -0,0 +1,97 @@ +# The `standard` agent preset: the full IDE coding agent. +# +# Adapted from deepseek-harness/apps/cli/config/agent-presets/standard (MIT, +# Copyright (c) DeepSeek — see ../../NOTICE.md). The CLI's rows for surfaces an +# IDE launch does not compose are dropped: pwsh, plan mode, the ask-user and web +# tools, workflows/ralph, and the codex/claude-code subagent products. +# +# This file is an AGENT-PLANE composition. `dsh-agent-presets` mounts it ONCE +# per process under a standing scope, and every session naming this preset joins +# it by scope parentage. The host composition (../../cordis.yml) keeps what a +# preset must not own: the registries themselves, the model route, the sandbox +# and approval stack, persistence, and the ACP transport. +# +# A row that PROVIDES a service must sit inside a group carrying an `isolate` +# realm, or it publishes into the process-global root realm where a second +# preset registering the same name collides. Every row below only registers into +# host registries (tools, prompt sections, skills), so none needs a realm. + +# ── identity ──────────────────────────────────────────────────────────────── + +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and the cwd the +# ACP client supplied at session/new. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: |- + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# The model-facing tool only. Its executor (`bash-sandbox`) and the environment +# registry (`shell-env`) are host-plane. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# Both register into the host `tools` registry and provide nothing. The `fs` +# service and its observation policy stay in the host composition. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: true + +# ── background jobs ───────────────────────────────────────────────────────── + +# Only the model-facing controls; the task registry is host-plane because +# `tool-bash` above resolves it with `ctx.get` and would not see a realm here. +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + +# ── skills and goals ──────────────────────────────────────────────────────── + +# The skill registry, its filesystem provider, and the goal service all live in +# the host composition; these rows are the model-facing halves. +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── delegation ────────────────────────────────────────────────────────────── + +# The `subagents` registry, the spawn backend, and `tool-subagent-report` are +# host-plane singletons; this preset contributes the tools that resolve them. +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + +- id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + maxDepth: 1 + +# ── planning ──────────────────────────────────────────────────────────────── + +# `todo_write` replaces the logged whole list for later model requests; the ACP +# bridge republishes each write as a plan update. +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true diff --git a/packages/dsh-acp/presets/standard/preset.yml b/packages/dsh-acp/presets/standard/preset.yml new file mode 100644 index 0000000000..2f346ac707 --- /dev/null +++ b/packages/dsh-acp/presets/standard/preset.yml @@ -0,0 +1,3 @@ +name: 标准模式 +description: 功能完整的编码 Agent:文件读写与检索、沙箱 Shell、后台任务、Skills、目标、子代理与 todo 计划。 +order: 1 diff --git a/packages/dsh-acp/scripts/build-profile.mjs b/packages/dsh-acp/scripts/build-profile.mjs new file mode 100644 index 0000000000..b770c08cbc --- /dev/null +++ b/packages/dsh-acp/scripts/build-profile.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +/** + * Package the built bridge as a DeepSeek Harness PROFILE, so an IDE launch is + * `dsh --profile ` against the harness the user already installed. + * + * A profile is just a directory under `$DSH_HOME/profiles/`: a `package.json` + * naming its bundle layers and a `cordis.patch.yml` holding entries. This + * script writes one whose bundle list is EMPTY, which makes our composition the + * whole tree — no in-box bundle contributes rows, so a mode like `minimal` + * stays exactly as narrow as its preset says. The launcher rewrites a profile's + * `cordis.yml` to an empty root on every boot, which is why the composition + * ships as the patch layer instead. + * + * Nothing here installs a second copy of the harness. Every `@deepseek-ai/dsh-*` + * row resolves from the user's own dsh through `$DSH_HOME/profiles/node_modules`, + * the flat fallback the launcher maintains (one symlink per package in the + * installation's dependency closure). Only the two packages OUTSIDE that + * closure are vendored here. + * + * Usage: `node scripts/build-profile.mjs [--from-local] [--out ]` + * `--from-local` copies the vendored packages out of this package's own + * node_modules (a linked harness checkout) instead of fetching the pinned + * versions from the registry; use it offline, and expect a checkout's version + * rather than the pinned one. + */ + +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +/** The profile directory BitFun materializes this build into. */ +export const PROFILE_NAME = 'bitfun-acp' + +/** + * The oldest `@deepseek-ai/dsh` this build is known to boot against. The bridge + * compiles against harness APIs; an older installation resolves the closure to + * packages this code predates, and the failure would otherwise surface as a + * module or service error with no hint about the cause. + */ +const MIN_DSH_VERSION = '0.1.0-rc.6' + +/** + * The only two runtime packages the harness installation does NOT already + * expose: the ACP wire SDK (not a harness dependency at all) and the agent + * spine, which ships as an examples package outside the `dsh` closure. Pinned, + * because they must agree with the harness train named above. + */ +const VENDORED = { + '@agentclientprotocol/sdk': '0.25.1', + '@deepseek-ai/dsh-agent-spine-demo': '0.1.0-rc.6', +} + +/** Basename of the build stamp BitFun compares before re-materializing. */ +const STAMP_FILENAME = '.bitfun-bridge.json' + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { out: { type: 'string' }, 'from-local': { type: 'boolean' } }, + strict: true, +}) + +const OUT = resolve(PACKAGE_ROOT, values.out ?? 'dist-profile') + +/** + * Turn the leaf `cordis.yml` into a profile patch layer. + * + * The transform is textual on purpose: every comment in that file explains why + * a row is where it is, and re-serializing through a YAML parser would drop all + * of it. Indenting the whole document under one `- insert:` is exactly the + * shape the in-box bundles use. + * @param source - the contents of cordis.yml. + * @returns the patch document. + */ +function toPatchLayer(source) { + const body = source + // The app row is loaded from source in development and from the emitted + // tree here; the Loader resolves both against the profile directory. + .replace(/^(\s*name:\s*)'\.\/src\/app\.ts'/m, "$1'./lib/app.js'") + .split('\n') + // Blank lines stay blank rather than becoming trailing whitespace. + .map(line => line === '' ? '' : ` ${line}`) + .join('\n') + return `# GENERATED by scripts/build-profile.mjs from cordis.yml — do not edit.\n` + + `#\n` + + `# The profile's own patch layer, applied over an empty root: with no bundles\n` + + `# in package.json, these rows ARE the tree.\n` + + `- insert:\n${body}` +} + +/** + * Copy a directory into the output, skipping what never ships. + * @param from - absolute source directory. + * @param to - absolute destination directory. + */ +function copyTree(from, to) { + cpSync(from, to, { + recursive: true, + dereference: true, + filter: source => { + // `basename`, not a hand-rolled slice on '/': a Windows path separates on + // '\', where slicing on '/' yields the whole path and matches nothing. + const base = basename(source) + return base !== 'node_modules' && base !== '.git' && !base.endsWith('.map') + }, + }) +} + +/** + * Place one vendored package under the profile's own node_modules. + * + * Its transitive dependencies are NOT vendored: Node's parent walk continues + * past the profile into `$DSH_HOME/profiles/node_modules`, where the harness + * installation's closure already provides them. + * @param name - the package name. + * @param version - the pinned version, used when fetching from the registry. + */ +function vendor(name, version) { + const destination = join(OUT, 'node_modules', name) + mkdirSync(dirname(destination), { recursive: true }) + if (values['from-local'] === true) { + copyTree(join(PACKAGE_ROOT, 'node_modules', name), destination) + return + } + const staging = mkdtempSync(join(tmpdir(), 'dsh-acp-vendor-')) + try { + // Both commands run IN the staging directory so no argument ever carries an + // absolute path. That is not tidiness: `npm` on Windows is a `.cmd` shim, + // which Node refuses to spawn without a shell since CVE-2024-27980, and a + // shell then re-splits every argument — an unquoted `C:\Users\...` path + // would break on its first space. Keeping the arguments to bare package + // names and one filename sidesteps quoting altogether, and also keeps a + // drive letter away from `tar -f`, which GNU tar reads as a remote host. + const packed = execFileSync( + 'npm', + ['pack', `${name}@${version}`, '--silent'], + { cwd: staging, encoding: 'utf8', shell: process.platform === 'win32' }, + ) + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line !== '') + .at(-1) + if (packed === undefined) { + throw new Error(`npm pack ${name}@${version} named no tarball on stdout`) + } + execFileSync('tar', ['-xzf', packed], { cwd: staging }) + copyTree(join(staging, 'package'), destination) + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} + +/** + * Hash every file in the output so BitFun can tell one build from another. + * + * A version string is not enough: during development the bridge's version + * stands still while its code changes, and a stale profile on disk would look + * current. Paths are sorted so the digest does not depend on directory order, + * and recorded with '/' so the same sources hash the same on every build host + * rather than once per path separator. + * @param root - the output directory, already fully written except the stamp. + * @returns a hex digest over every path and its bytes. + */ +function hashTree(root) { + const files = [] + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) walk(path) + else files.push([relative(root, path).split(sep).join('/'), path]) + } + } + walk(root) + const digest = createHash('sha256') + for (const [key, path] of files.sort((a, b) => a[0] < b[0] ? -1 : 1)) { + digest.update(key) + digest.update('\0') + digest.update(readFileSync(path)) + } + return digest.digest('hex') +} + +const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) +try { + statSync(join(PACKAGE_ROOT, 'lib', 'app.js')) +} catch { + throw new Error('lib/app.js is missing; run `npm run build` (tsc -p tsconfig.build.json) first') +} + +rmSync(OUT, { recursive: true, force: true }) +mkdirSync(OUT, { recursive: true }) + +// A hand-written profile manifest is enough: the launcher only reads +// `dsh.profile.bundles` from it, and tolerates the section being absent. An +// empty list is deliberate — see the module comment. +writeFileSync(join(OUT, 'package.json'), `${JSON.stringify({ + name: `dsh-profile-${PROFILE_NAME}`, + version: manifest.version, + private: true, + type: 'module', + dependencies: {}, + dsh: { profile: { bundles: [] } }, +}, undefined, 2)}\n`) + +writeFileSync( + join(OUT, 'cordis.patch.yml'), + toPatchLayer(readFileSync(join(PACKAGE_ROOT, 'cordis.yml'), 'utf8')), +) + +copyTree(join(PACKAGE_ROOT, 'lib'), join(OUT, 'lib')) +copyTree(join(PACKAGE_ROOT, 'presets'), join(OUT, 'presets')) +for (const [name, version] of Object.entries(VENDORED)) vendor(name, version) + +writeFileSync(join(OUT, STAMP_FILENAME), `${JSON.stringify({ + bridge: manifest.version, + content: hashTree(OUT), + minDshVersion: MIN_DSH_VERSION, + profile: PROFILE_NAME, +}, undefined, 2)}\n`) + +process.stdout.write(`build-profile: wrote ${relative(PACKAGE_ROOT, OUT)} (profile ${PROFILE_NAME})\n`) diff --git a/packages/dsh-acp/scripts/link-local-dsh.mjs b/packages/dsh-acp/scripts/link-local-dsh.mjs new file mode 100644 index 0000000000..ee0967ac42 --- /dev/null +++ b/packages/dsh-acp/scripts/link-local-dsh.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/** + * Populate this package's node_modules by symlinking a local deepseek-harness + * checkout. + * + * This is the OPTIONAL developer path. The ordinary build installs the pinned + * `0.1.0-rc.6` train from this package's own `package-lock.json`; link against + * a checkout when you are changing the harness and the bridge together, and + * `scripts/prepare-dsh-profile.mjs` will then leave the linked tree alone. + * + * Usage: `node scripts/link-local-dsh.mjs [--repo ]` + * or set DSH_REPO. Falls back to sibling checkouts of this repository. + */ + +import { mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +/** Harness packages that must resolve from this package's own node_modules. */ +const HARNESS_PACKAGES = [ + // Imported by src/. + '@deepseek-ai/cordis', + '@deepseek-ai/schemastery', + '@deepseek-ai/dsh-agent', + '@deepseek-ai/dsh-agent-instructions', + '@deepseek-ai/dsh-agent-presets', + '@deepseek-ai/dsh-agent-spine-demo', + '@deepseek-ai/dsh-app-boot', + '@deepseek-ai/dsh-llm', + '@deepseek-ai/dsh-session', + '@deepseek-ai/dsh-session-checkpoint-policy', + '@deepseek-ai/dsh-session-persistence-jsonl', + '@deepseek-ai/dsh-session-query-sqlite', + '@deepseek-ai/dsh-tools', + '@deepseek-ai/dsh-user-approval', + // Named by cordis.yml — the HOST plane. The Loader resolves these against the + // config directory. + '@deepseek-ai/dsh-agent-default-model', + '@deepseek-ai/dsh-bash-sandbox', + '@deepseek-ai/dsh-code-runtime', + '@deepseek-ai/dsh-code-runtime-worker-thread', + '@deepseek-ai/dsh-compaction-basic', + '@deepseek-ai/dsh-credentials-local', + '@deepseek-ai/dsh-fs-observation-policy', + '@deepseek-ai/dsh-fs-sandbox', + '@deepseek-ai/dsh-goal', + '@deepseek-ai/dsh-goal-round-driver', + '@deepseek-ai/dsh-llm-deepseek', + '@deepseek-ai/dsh-llm-pi-ai', + '@deepseek-ai/dsh-settings-file', + '@deepseek-ai/dsh-repeat-tool-reminder', + '@deepseek-ai/dsh-sandbox-local', + '@deepseek-ai/dsh-sandbox-policy', + '@deepseek-ai/dsh-session-projection', + '@deepseek-ai/dsh-shell-env', + '@deepseek-ai/dsh-skill', + '@deepseek-ai/dsh-skill-filesystem', + '@deepseek-ai/dsh-subagent', + '@deepseek-ai/dsh-subagent-spawn-in-process', + '@deepseek-ai/dsh-subprocess-local', + '@deepseek-ai/dsh-token-meter', + '@deepseek-ai/dsh-tool-subagent-report', + // Named by presets/*/agent.cordis.yml — the AGENT plane. `dsh-agent-presets` + // resolves a preset row against the preset directory, which is inside this + // package, so these must resolve from here too. + '@deepseek-ai/dsh-agent-tool-presentation', + '@deepseek-ai/dsh-persona', + '@deepseek-ai/dsh-terminal', + '@deepseek-ai/dsh-terminal-bash', + '@deepseek-ai/dsh-tool-bash', + '@deepseek-ai/dsh-tool-bash-persistent', + '@deepseek-ai/dsh-tool-fs', + '@deepseek-ai/dsh-tool-fs-search', + '@deepseek-ai/dsh-tool-goal', + '@deepseek-ai/dsh-tool-jobs', + '@deepseek-ai/dsh-tool-skill', + '@deepseek-ai/dsh-tool-str-replace-editor', + '@deepseek-ai/dsh-tool-subagent', + '@deepseek-ai/dsh-tool-subagent-control', + '@deepseek-ai/dsh-tool-todo', + // Used only by the test suite. The Loader is a runtime dependency of the app + // (through dsh-app-boot), but the preset specs mount it themselves, so it has + // to resolve from here as well. + '@deepseek-ai/cordis-plugin-loader', + '@deepseek-ai/dsh-agent-loop', + '@deepseek-ai/dsh-agent-loop-testkit', +] + +/** Third-party packages taken from the harness checkout's own install. */ +const VENDOR_PACKAGES = [ + '@agentclientprotocol/sdk', + 'tsx', + 'typescript', + 'vitest', +] + +/** Directory globs (one level of `*` each) that hold harness workspace members. */ +const WORKSPACE_GLOBS = [ + 'vendor/*', + 'packages/*/*', + 'apps/*', + 'native/landlock-run', + 'native/landlock-run/packages/*', +] + +/** + * Find the harness checkout to link against. + * @param explicit - a `--repo` value or DSH_REPO, when supplied. + * @returns the absolute path of a directory that looks like deepseek-harness. + */ +function resolveHarnessRepo(explicit) { + const candidates = explicit === undefined + ? [ + resolve(PACKAGE_ROOT, '../../../deepseek-harness'), + resolve(PACKAGE_ROOT, '../../../../deepseek-harness'), + resolve(PACKAGE_ROOT, '../../../../../deepseek-harness'), + ] + : [resolve(process.cwd(), explicit)] + for (const candidate of candidates) { + try { + const manifest = JSON.parse(readFileSync(join(candidate, 'package.json'), 'utf8')) + if (manifest.name === '@deepseek-ai/dsh-root') return candidate + } catch { + // Not a harness checkout; try the next candidate. + } + } + throw new Error( + `no deepseek-harness checkout found (looked at ${candidates.join(', ')}); ` + + 'pass --repo or set DSH_REPO', + ) +} + +/** + * Index every workspace member of the harness checkout by package name. + * @param repo - the harness checkout root. + * @returns a map from package name to its absolute directory. + */ +function indexWorkspace(repo) { + const index = new Map() + const dirs = [] + for (const glob of WORKSPACE_GLOBS) { + const parts = glob.split('/') + let level = [repo] + for (const part of parts) { + const next = [] + for (const base of level) { + if (part !== '*') { + next.push(join(base, part)) + continue + } + let entries = [] + try { + entries = readdirSync(base, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.')) next.push(join(base, entry.name)) + } + } + level = next + } + dirs.push(...level) + } + for (const dir of dirs) { + try { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + if (typeof manifest.name === 'string') index.set(manifest.name, dir) + } catch { + // Not a package directory. + } + } + return index +} + +/** + * Replace `linkPath` with a symlink to `target`, creating scope directories. + * @param linkPath - absolute path of the link to (re)create. + * @param target - absolute path the link points at. + */ +function relink(linkPath, target) { + mkdirSync(dirname(linkPath), { recursive: true }) + rmSync(linkPath, { recursive: true, force: true }) + symlinkSync(target, linkPath, 'dir') +} + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { repo: { type: 'string' } }, + strict: true, +}) + +const repo = resolveHarnessRepo(values.repo ?? process.env.DSH_REPO) +const workspace = indexWorkspace(repo) +const nodeModules = join(PACKAGE_ROOT, 'node_modules') +const missing = [] + +for (const name of HARNESS_PACKAGES) { + const dir = workspace.get(name) + if (dir === undefined) { + missing.push(`${name} (no workspace member with that name in ${repo})`) + continue + } + relink(join(nodeModules, name), dir) +} + +for (const name of VENDOR_PACKAGES) { + const dir = join(repo, 'node_modules', name) + try { + statSync(dir) + } catch { + missing.push(`${name} (not installed at ${dir}; run pnpm install in the harness checkout)`) + continue + } + relink(join(nodeModules, name), dir) +} + +// The harness checkout's .bin shims are relative to their own realpath, so a +// single link gives this package working `vitest`/`tsx`/`tsc` executables. +relink(join(nodeModules, '.bin'), join(repo, 'node_modules', '.bin')) + +if (missing.length > 0) { + process.stderr.write(`link-local-dsh: ${missing.length} dependency(ies) could not be linked:\n`) + for (const line of missing) process.stderr.write(` - ${line}\n`) + process.exit(1) +} + +process.stdout.write( + `link-local-dsh: linked ${HARNESS_PACKAGES.length + VENDOR_PACKAGES.length} packages from ${repo}\n`, +) diff --git a/packages/dsh-acp/scripts/smoke.mjs b/packages/dsh-acp/scripts/smoke.mjs new file mode 100644 index 0000000000..ca374c5dd6 --- /dev/null +++ b/packages/dsh-acp/scripts/smoke.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * Boot smoke check: spawn the adapter exactly as an IDE would, complete the ACP + * handshake, open a session, and report what came back. + * + * This is the cheap half of end-to-end — it contacts no provider, because one + * is only reached on `session/prompt`. Pass `--prompt ""` to also drive + * one real turn and print every session update; that half needs a DeepSeek key + * the harness can resolve, which is any of `DEEPSEEK_API_KEY` in this + * environment, `$DSH_HOME/.credentials.yaml` (what dsh's Models page writes), + * or a `.env` fallback. This script asserts none of them: the credential chain + * belongs to the composition, and a missing key surfaces as a failed turn. + * + * Permission requests are answered `allow-once`, or `reject-once` with + * `--reject` — enough to exercise both branches of the approval path. + * + * `--cancel-after ` interrupts the turn mid-flight and then sends a second + * prompt, so a session that cancels but cannot continue fails loudly. + * + * `--mode ` sets the session's mode config option before that prompt, + * switching the agent preset. It stays in the credential-free half: a mode + * switch is answered by the bridge and never reaches the provider. + * + * `--load ` reopens a stored session instead of starting one, which + * is what an IDE does after a restart: the run then prints the replayed history + * and the mode the session comes back locked to. The id is a dsh session id — + * a directory name under `$DSH_HOME/acp-sessions//` — and `--cwd` must + * name the workspace it was created in. + * + * Usage: `node scripts/smoke.mjs [--mode code] [--load ] [--prompt "…"] [--reject] [--cancel-after 3000]` + */ + +import { spawn } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { Readable, Writable } from 'node:stream' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' +import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk' + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + prompt: { type: 'string' }, + load: { type: 'string' }, + mode: { type: 'string' }, + profile: { type: 'string' }, + cwd: { type: 'string' }, + reject: { type: 'boolean' }, + 'cancel-after': { type: 'string' }, + }, + strict: true, +}) + +/** Which option this run answers every permission request with. */ +const PERMISSION_CHOICE = values.reject === true ? 'reject-once' : 'allow-once' + +/** The workspace the session opens against — an IDE passes the user's project. */ +const WORKSPACE = resolve(process.cwd(), values.cwd ?? PACKAGE_ROOT) + +// Two launch routes, one composition. `--profile` is what BitFun ships: the +// user's installed `dsh` boots the materialized profile under $DSH_HOME, and +// every harness package resolves from that installation. Without it, this runs +// the working copy through tsx, which is the development loop. +const launch = values.profile === undefined + ? { command: process.execPath, args: ['--import', 'tsx', 'src/bin.ts', '--config', './cordis.yml'] } + : { command: 'dsh', args: ['--profile', values.profile] } + +const child = spawn( + launch.command, + launch.args, + { cwd: PACKAGE_ROOT, stdio: ['pipe', 'pipe', 'inherit'] }, +) + +const stream = ndJsonStream( + Writable.toWeb(child.stdin), + Readable.toWeb(child.stdout), +) + +/** Print one session update in a single readable line. */ +function describe(update) { + const kind = update.sessionUpdate + if (kind === 'tool_call' || kind === 'tool_call_update') { + const label = update.title === undefined ? '' : ` ${update.title}` + return `${kind} ${update.toolCallId}${label} [${update.status ?? '-'}]` + } + if (kind === 'agent_message_chunk' || kind === 'agent_thought_chunk') { + return `${kind} ${JSON.stringify(update.content.text ?? '')}` + } + if (kind === 'plan') return `plan ${update.entries.map(entry => `${entry.status}:${entry.content}`).join(' | ')}` + if (kind === 'config_option_update') { + return `config_option_update ${describeOptions(update.configOptions)}` + } + return kind +} + +/** Print a config option set the way a client's picker would show it. */ +function describeOptions(configOptions) { + if (configOptions === undefined || configOptions.length === 0) return '(none)' + return configOptions.map(option => { + const values = option.type === 'select' + ? option.options.map(value => (value.value === option.currentValue ? `[${value.value}]` : value.value)).join(' ') + : String(option.currentValue) + return `${option.id}(${option.category ?? '-'}): ${values}` + }).join(' | ') +} + +const client = new ClientSideConnection(() => ({ + sessionUpdate(params) { + process.stdout.write(` update: ${describe(params.update)}\n`) + return Promise.resolve() + }, + requestPermission(params) { + process.stdout.write( + ` permission: ${params.toolCall.toolCallId} (${params.toolCall.title ?? '?'}) -> ${PERMISSION_CHOICE}\n`, + ) + return Promise.resolve({ outcome: { outcome: 'selected', optionId: PERMISSION_CHOICE } }) + }, +}), stream) + +let failed = false +try { + const initialized = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + process.stdout.write(`initialize: ${JSON.stringify(initialized.agentInfo)} v${initialized.protocolVersion}\n`) + process.stdout.write(`loadSession capability: ${String(initialized.agentCapabilities?.loadSession === true)}\n`) + + // `--load` is the reopen path an IDE takes on restart: the session comes back + // from storage with its history replayed as updates and its mode already + // fixed, instead of a blank session under the roster default. + const session = values.load === undefined + ? await client.newSession({ cwd: WORKSPACE, mcpServers: [] }) + : { sessionId: values.load, ...await client.loadSession({ sessionId: values.load, cwd: WORKSPACE, mcpServers: [] }) } + const sessionId = session.sessionId + process.stdout.write(`${values.load === undefined ? 'newSession' : 'loadSession'}: ${sessionId}\n`) + process.stdout.write(`modes: ${describeOptions(session.configOptions)}\n`) + + if (values.mode !== undefined) { + const switched = await client.setSessionConfigOption({ + sessionId, + configId: 'agent-preset', + value: values.mode, + }) + process.stdout.write(`mode ${values.mode}: ${describeOptions(switched.configOptions)}\n`) + } + + if (values.prompt !== undefined) { + const pending = client.prompt({ sessionId, prompt: [{ type: 'text', text: values.prompt }] }) + if (values['cancel-after'] !== undefined) { + await new Promise(resolve => setTimeout(resolve, Number(values['cancel-after']))) + process.stdout.write('cancel: sent\n') + await client.cancel({ sessionId }) + } + const result = await pending + process.stdout.write(`prompt: ${result.stopReason}\n`) + + if (values['cancel-after'] !== undefined) { + // The point of the cancel probe: the session must still take work. + const next = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'say OK and nothing else' }] }) + process.stdout.write(`prompt after cancel: ${next.stopReason}\n`) + } + } +} catch (error) { + failed = true + process.stdout.write(`FAILED: ${String(error)}\n`) +} + +child.stdin.end() +await new Promise(resolve => child.once('exit', resolve)) +process.exit(failed ? 1 : 0) diff --git a/packages/dsh-acp/src/app.ts b/packages/dsh-acp/src/app.ts new file mode 100644 index 0000000000..03a7b3d03e --- /dev/null +++ b/packages/dsh-acp/src/app.ts @@ -0,0 +1,200 @@ +/** + * The IDE-facing ACP server app: the default agent spine + * ({@link @deepseek-ai/dsh-agent-spine-demo}), JSONL session persistence, and the + * {@link @bitfun/dsh-acp/bridge} protocol bridge, owned through one ordered + * lifecycle so ACP sessions quiesce before persistence detaches. + * + * Forked from `@deepseek-ai/dsh-acp-demo` + * (deepseek-harness/packages/examples/acp-demo/src/index.ts, MIT). + * Copyright (c) DeepSeek. See ../NOTICE.md. The only change is the transport: + * this app mounts the IDE bridge instead of the automation-only one. + * + * It writes nothing to stdout, pre-creates no agents, and leaves adapters, + * executors, and optional tools to the leaf `cordis.yml`, which must likewise + * avoid stdout loggers. Named exports are required so Loader retains this + * plugin's `Config` schema. + * + * @module @bitfun/dsh-acp + */ + +import type { Context } from '@deepseek-ai/cordis' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import z from '@deepseek-ai/schemastery' +import AgentPresets from '@deepseek-ai/dsh-agent-presets' +import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' +import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions' +import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import JsonlSessionPersistence, { + JsonlCompressionSchema, + type JsonlCompression, +} from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' +import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' +import * as acp from './bridge.ts' + +export const name = 'acp-ide-app' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' +const DEFAULT_PRESET = 'standard' + +/** + * The shipped preset roster: `/presets`, resolved from this module + * rather than from `process.cwd()`. An ACP client launches the adapter with the + * USER's workspace as the working directory, so a relative root would look for + * presets inside whatever project happens to be open. + * + * `../presets` is correct from both `src/app.ts` and the built `lib/app.js`. + */ +const SHIPPED_PRESET_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../presets') + +/** + * App config: the swappable per-deployment values. `provider` and `model` + * configure each agent the ACP bridge creates at `session/new`; `persona` is the + * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is the + * explicit model-facing tool order; `tools` is the tool registry's config; + * `persistenceRoot` is the JSONL backend's directory. + */ +export interface Config { + /** + * Provider route for ACP-created agents. Omitted, each session starts on the + * deployment's default selection (`ctx.agentDefaultModel`), which is what the + * harness's own settings document configures. + */ + provider?: string + /** Model name for ACP-created agents (must have a registered adapter); see {@link Config.provider}. */ + model?: string + /** Publish model reasoning as `agent_thought_chunk` updates. Defaults to `true`. */ + reasoning?: boolean + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona (the system-prompt plugin's `persona` config). */ + persona?: string + /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config). */ + toolOrder?: string[] + /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo). */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Fallback session-title limits forwarded through agent-spine-demo. */ + sessionTitle?: NonNullable + /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** + * Preset an ACP session composes when the client picks none. Must name a + * directory under {@link SHIPPED_PRESET_ROOT} or the user preset root. + * Defaults to `standard`. + */ + defaultPreset?: string + /** + * Also scan `/.agent-presets`, so a user can drop in their own mode + * beside the shipped three. Defaults to `true`. + */ + userPresets?: boolean + /** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `true`. */ + packChunks?: boolean + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] + /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-core. */ + toolBash?: NonNullable + /** Process-local background-job admission config forwarded through agent-core. */ + jobs?: NonNullable + /** Generic background-job controls forwarded through agent-core; set false to omit their tools. */ + toolJobs?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ + goals?: agentCore.GoalConfig | false +} + +export const Config: z = z.object({ + provider: z.string(), + model: z.string(), + reasoning: z.boolean().default(true), + maxParallelToolCalls: z.number().step(1).min(1), + persona: z.string(), + // The array default is forced to undefined: ABSENT means "lexicographic + // order" (the owning dsh-system-prompt schema does the same), while + // schemastery's native [] default would read as an invalid configured list. + toolOrder: z.array(z.string()).default(undefined as unknown as string[]), + tools: ToolRuntime.Config, + dshHome: z.string(), + sessionTitle: agentCore.SessionTitleConfigSchema, + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + defaultPreset: z.string().default(DEFAULT_PRESET), + userPresets: z.boolean().default(true), + packChunks: z.boolean().default(true), + persistenceCompression: JsonlCompressionSchema, + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), + skills: agentCore.SkillConfigSchema, + toolBash: agentCore.ToolBashConfigSchema, + jobs: agentCore.JobsConfigSchema, + toolJobs: z.union([z.const(false), agentCore.ToolJobsConfigSchema]), + goals: z.union([z.const(false), agentCore.GoalConfigSchema]), +}) + +/** + * Compose the spine with the IDE ACP transport. The agent-spine-demo bundle + * pre-creates NO agents (its `agents` list defaults to `[]`) and carries the + * deployment `persona`; the JSONL backend and derived query index persist under + * `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates one + * agent per `session/new` from the provider/model pair. The composite effect + * unloads in reverse order, keeping checkpoint and persistence listeners + * attached until ACP agents have flushed their closing events. No logger, no + * `hmr` — stdout stays pure. + * @param ctx - the Cordis context this app mounts into. + * @param config - the deployment's provider, model, persona, and persistence selection. + */ +export async function apply(ctx: Context, config: Config): Promise { + const goals = config.goals ?? {} + const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT + // A client that closes this process's stdin has ended the conversation: the + // IDE quit, or it tore the adapter down. This lives in the app rather than in + // a bin script because `dsh --profile` boots the composition directly and + // never runs one — both launch routes must shut down the same way. Disposing + // the ROOT (not just this plugin) drains the whole tree in reverse, so ACP + // sessions quiesce before persistence detaches. + ctx.effect(() => { + const close = (): void => { + void ctx.root.fiber.dispose().then(() => { process.exit(0) }) + } + process.stdin.on('end', close) + return () => void process.stdin.off('end', close) + }, 'acp-ide.stdin-close') + await ctx.effect(async function* () { + const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) + await spine + yield spine.dispose + // Before the transport, because the bridge composes every agent it creates + // from a preset and a missing roster would leave each session on the empty + // global tool layer. + const presets = ctx.plugin(AgentPresets, { + default: config.defaultPreset ?? DEFAULT_PRESET, + roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }], + includeUserRoot: config.userPresets ?? true, + }) + await presets + yield presets.dispose + const persistence = ctx.plugin(JsonlSessionPersistence, { + root: persistenceRoot, + ...config.packChunks !== undefined ? { packChunks: config.packChunks } : {}, + ...config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }, + }) + await persistence + yield persistence.dispose + const checkpoint = ctx.plugin(sessionCheckpointPolicy) + await checkpoint + yield checkpoint.dispose + const query = ctx.plugin(SqliteSessionQueryEngine, { path: join(persistenceRoot, 'session-query.db') }) + await query + yield query.dispose + const transport = ctx.plugin(acp, { + ...config.provider === undefined ? {} : { provider: config.provider }, + ...config.model === undefined ? {} : { model: config.model }, + ...config.reasoning === undefined ? {} : { reasoning: config.reasoning }, + }) + await transport + yield transport.dispose + }, 'acp-ide.composition') +} diff --git a/packages/dsh-acp/src/bin.ts b/packages/dsh-acp/src/bin.ts new file mode 100644 index 0000000000..043ef154d7 --- /dev/null +++ b/packages/dsh-acp/src/bin.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Boot an IDE-facing ACP stdio server from `cordis.yml`; usage is + * `dsh-acp-ide [--config path]`, defaulting to `./cordis.yml`. Shared env + * loading, Loader guards, snapshot config selection, and settled-tree boot live + * in dsh-app-boot. Stdout is reserved for JSON-RPC, so diagnostics go only to + * stderr. + * + * Forked from `@deepseek-ai/dsh-acp-demo/bin` + * (deepseek-harness/packages/examples/acp-demo/src/bin.ts, MIT). + * Copyright (c) DeepSeek. See ../NOTICE.md. + * + * @module @bitfun/dsh-acp/bin + */ + +import { parseArgs } from 'node:util' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' + +const NAME = 'dsh-acp-ide' + +installFailLoud(NAME) +const snapshotMode = process.env['DSH_SNAPSHOT'] +if (snapshotMode !== 'replay') loadEnv(NAME) +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { config: { type: 'string', short: 'c' } }, + strict: true, +}) +await boot(NAME, resolveConfigPath(values.config ?? './cordis.yml', snapshotMode)) +// Shutdown on stdin close belongs to the app plugin, not to this script: the +// same composition also boots under `dsh --profile`, which runs no bin at all. diff --git a/packages/dsh-acp/src/bridge.ts b/packages/dsh-acp/src/bridge.ts new file mode 100644 index 0000000000..d1f36d22ab --- /dev/null +++ b/packages/dsh-acp/src/bridge.ts @@ -0,0 +1,951 @@ +/** + * IDE-oriented Agent Client Protocol server over JSON-RPC stdio. + * + * Forked from `@deepseek-ai/dsh-acp` + * (deepseek-harness/packages/acp/acp/src/index.ts, MIT). + * Copyright (c) DeepSeek. See ../NOTICE.md. + * + * The upstream bridge is automation-only by design: it carries prompt text, + * committed assistant text, cancellation, and one-shot permission decisions, + * and deliberately keeps tool calls, reasoning, and plans off the wire as + * "presentation or trace data". An IDE is exactly the client that needs that + * presentation data, so this fork keeps upstream's session, turn, and teardown + * machinery verbatim and replaces only the `session/event` dispatch with a full + * translation to ACP session updates. + * + * @module @bitfun/dsh-acp/bridge + */ + +import type { Context } from '@deepseek-ai/cordis' +import { randomUUID } from 'node:crypto' +import { isAbsolute } from 'node:path' +import { Readable, Writable } from 'node:stream' +import Schema from '@deepseek-ai/schemastery' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { + AgentSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + RequestError, + type Agent as AcpAgent, + type AuthenticateRequest, + type CancelNotification, + type InitializeRequest, + type InitializeResponse, + type LoadSessionRequest, + type LoadSessionResponse, + type NewSessionRequest, + type NewSessionResponse, + type PlanEntry, + type PromptRequest, + type PromptResponse, + type SessionConfigOption, + type SessionConfigSelectOption, + type SessionNotification, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type StopReason, + type Stream, + type ToolCallUpdate, +} from '@agentclientprotocol/sdk' +import type { Agent } from '@deepseek-ai/dsh-agent' +// The roster itself is read through `ctx.get`; only the stored-preset resolver +// is a value here. This import also carries the `agent-preset/selected` +// session-event declaration the switch below logs and the resolver reads back. +import { resolveSessionPreset, type AgentPreset } from '@deepseek-ai/dsh-agent-presets' +import { + SessionId, type SessionEvent, type SessionHeader, type TurnEndReason, +} from '@deepseek-ai/dsh-session' +// Side-effect type import: declaration-merges the approval waterfall answered below. +import type {} from '@deepseek-ai/dsh-user-approval' +import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts' +import { parseToolArguments, toolKind, toolLocations, toolOutcome } from './tool-view.ts' + +export const name = 'acp-ide' +/** The bridge creates and owns agents; every other concern is carried by the agent composition. */ +export const inject = ['agents'] + +/** + * The single continuable-subagent teardown the bridge needs. Declared + * structurally so this package does not depend on the subagent seam for one + * shutdown hook; an absent service means nothing continuable was materialized. + */ +interface ContinuableDrain { + /** + * Close admission below exact host-owned parents, then dispose only their + * continuable descendants child-first. + */ + drainContinuableDescendants(parents: readonly Agent[]): Promise +} + +/** + * The deployment's default model selection, declared structurally for the same + * reason as {@link ContinuableDrain}: the bridge reads it when its own config + * pins no provider/model, but does not depend on the settings seam to exist. A + * composition without the service falls back to this plugin's config. + */ +interface DefaultModelSource { + /** The provider/model a new agent should start on, live at call time. */ + currentSelection(): { provider?: string; model?: string } +} + +/** + * The stored-session reads `session/load` needs, declared structurally for the + * same reason as {@link ContinuableDrain}. A deployment composed without + * durable persistence simply has nothing to load, and the bridge answers that + * as a protocol error rather than depending on the storage seam to exist. + */ +interface SessionArchive { + /** + * One stored session's header and full event log. This is the authoritative + * read rather than a listing: it waits for a just-disposed session's final + * drain, which is exactly the session a client reopens first. + */ + inspect(id: SessionId): Promise<{ + readonly meta: SessionHeader + readonly events: readonly SessionEvent[] + }> +} + +/** Preserve invalid-parameter detail in the SDK wire error message. */ +function invalidParams(detail: string): RequestError { + return RequestError.invalidParams(undefined, detail) +} + +/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ +function internalError(detail: string): RequestError { + return RequestError.internalError(undefined, detail) +} + +/** Plugin config: the provider/model selection used for each ACP-created agent. */ +export interface AcpConfig { + /** Provider route for created agents; omitted defers to the deployment default. */ + provider?: string + /** Model name for created agents; omitted defers to the deployment default. */ + model?: string + /** + * Publish `agent_thought_chunk` updates for model reasoning. Clients that + * render reasoning inline want this; leave it off for a quieter wire. + * Defaults to `true`. + */ + reasoning?: boolean + /** Runtime-only transport override; production uses stdio. */ + stream?: Stream +} + +export const Config: Schema = Schema.object({ + provider: Schema.string(), + model: Schema.string(), + reasoning: Schema.boolean().default(true), +}) + +/** What this bridge already told the client about one tool call. */ +interface PublishedCall { + title: string + rawInput: unknown +} + +/** Per-session protocol state. */ +interface SessionRecord { + agent: Agent + /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ + dispose: () => Promise + /** + * `turn:step` keys whose committed assistant text was already streamed as + * deltas, so the committed message is not published a second time. + */ + streamedText: Set + /** The same de-duplication ledger for reasoning. */ + streamedReasoning: Set + /** + * Tool calls this bridge has published a `tool_call` update for, so the + * approval hook can tell a client-visible call from one it must announce. + */ + published: Map + /** + * The agent preset this session is composed from, absent when the deployment + * composes no roster. Tracked here so the mode picker can report what is + * already running without re-reading the scope chain. + */ + presetId: string | undefined + /** + * Tail of this session's preset switches. Two `session/set_config_option` + * requests must not recompose concurrently: the second one's blank-session + * check has to see the first one's result, not the state it raced past. + */ + presetSwitch: Promise | undefined + /** Whether the client has been told the mode is now fixed, so it is said once. */ + presetLockPublished: boolean + /** In-flight prompt and its captured turn number for exact settlement. */ + inflight: { + resolve: (reason: StopReason) => void + reject: (error: Error) => void + messageId: string + turn: number | undefined + /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */ + endReason: TurnEndReason | undefined + } | undefined +} + +/** Key a streaming ledger entry by the model round that produced it. */ +function stepKey(turn: number, step: number): string { + return `${turn}:${step}` +} + +/** + * The session config option the preset roster is offered under. + * + * ACP has no command channel to the agent — a client's slash menu inserts the + * command as ordinary prompt text, so a mode chosen that way arrives glued to + * the user's own message. A config option of category `mode` is the channel the + * spec provides for exactly this: the client renders it as a picker next to its + * composer and sends the choice on its own request. + */ +const PRESET_CONFIG_ID = 'agent-preset' + +/** + * Why the picker is down to one entry, in the client's own words. + * + * Doubles as the error a late `session/set_config_option` is refused with, so a + * client that asks anyway is told the same thing its UI already showed. + */ +const PRESET_LOCKED = 'This conversation has already started, so its mode is fixed. ' + + 'Start a new session to pick another mode.' + +/** + * Whether a session may still change its preset. + * + * A started conversation's history was produced under its preset's tools, so + * swapping now would leave logged tool calls the new composition cannot make. + * The roster states the rule and leaves the check to its caller. + * @param session - the agent's session. + * @returns true while no turn has started. + */ +function sessionBlank(session: Agent['session']): boolean { + return !session.events.some(event => event.type === 'turn/start') +} + +/** + * One roster entry as a selectable value of the mode option. + * @param preset - the roster entry. + * @returns its id as the value, with whatever prose the preset carries. + */ +function presetValue(preset: AgentPreset): SessionConfigSelectOption { + return { + value: preset.id, + name: preset.name ?? preset.id, + ...preset.description === undefined ? {} : { description: preset.description }, + } +} + +/** + * Mount the IDE-facing ACP server. + * @param ctx - Cordis context carrying the agent factory and session events. + * @param config - Initial provider/model selection and optional test transport. + */ +export function apply(ctx: Context, config: AcpConfig): void { + // ACP handlers execute outside this plugin's injection scope, so capture the + // injected service during apply rather than reading it lazily in a callback. + const agents = ctx.agents + // The preset roster is optional. A deployment that keeps every model-facing + // plugin in its host composition has no modes to offer, and this bridge then + // behaves exactly as it did before they existed. + const presets = ctx.get('agentPresets') + const logger = ctx.logger + const sessions = new Map() + const publishReasoning = config.reasoning ?? true + let closed = false + let conn: AgentSideConnection + + /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ + const ownedRecord = (agent: Agent): SessionRecord | undefined => { + const record = sessions.get(agent.session.id) + return record?.agent === agent ? record : undefined + } + + const assertOpen = (): void => { + if (closed) throw internalError('the ACP bridge has been disposed') + } + + const requireSession = (sessionId: SessionId): SessionRecord => { + const record = sessions.get(sessionId) + if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`) + return record + } + + /** Send a protocol update without letting a disconnected client fail an agent turn. */ + const notify = (notification: SessionNotification): void => { + /* v8 ignore next 3 -- only a transport write failure reaches this guard. */ + void conn.sessionUpdate(notification).catch((error: unknown) => { + logger.warn(`acp: session/update failed: ${String(error)}`) + }) + } + + /** Send one update for a bridge-owned session. */ + const update = (record: SessionRecord, payload: SessionNotification['update']): void => { + notify({ sessionId: record.agent.session.id, update: payload }) + } + + /** + * The session's mode picker: the roster as one `select` config option. + * + * Broken presets are withheld — the roster keeps them so their directory + * stays visible to an author, but offering one would advertise a choice every + * attempt refuses. A locked session is published with only the mode in force, + * which is the client-generic way to say "this cannot be changed": a client + * disables a picker it has nothing left to pick from, and an agent that does + * allow mid-conversation switching keeps its full list and stays switchable. + * @param record - the session. + * @param locked - whether the mode is already fixed; read off the session by default. + * @returns the one option, or nothing when this deployment composes no roster. + */ + const presetOptions = async ( + record: SessionRecord, locked = !sessionBlank(record.agent.session), + ): Promise => { + if (presets === undefined) return [] + const roster = await presets.list() + const current = record.presetId + const offered = roster + .filter(preset => preset.broken === undefined) + .filter(preset => !locked || preset.id === current) + const currentValue = current ?? offered[0]?.id + if (currentValue === undefined || offered.length === 0) return [] + return [{ + type: 'select', + id: PRESET_CONFIG_ID, + name: 'Mode', + category: 'mode', + ...locked ? { description: PRESET_LOCKED } : {}, + currentValue, + options: offered.map(presetValue), + }] + } + + const settlePrompt = (record: SessionRecord, reason: StopReason): void => { + const inflight = record.inflight + if (inflight === undefined) return + record.inflight = undefined + inflight.resolve(reason) + } + + const rejectFromError = ( + inflight: NonNullable, + reason: Extract, + ): void => { + inflight.reject(internalError(`turn failed: ${reason.error.message}`)) + } + + /** + * Announce a tool call to the client. Idempotent per call id so the approval + * hook can safely re-announce a call whose `tool/call` event it did not see. + */ + const publishToolCall = ( + record: SessionRecord, callId: string, toolName: string, rawInput: unknown, + ): PublishedCall => { + const existing = record.published.get(callId) + if (existing !== undefined) return existing + const published: PublishedCall = { title: toolName, rawInput } + record.published.set(callId, published) + update(record, { + sessionUpdate: 'tool_call', + toolCallId: callId, + // The harness tool name IS the title: a client matches it against its own + // tool vocabulary to render a native card, and a prettified label would + // only make that match guess. + title: toolName, + kind: toolKind(toolName), + status: 'pending', + rawInput, + locations: toolLocations(rawInput), + }) + return published + } + + /** + * Translate one harness session event to the ACP wire. Everything the + * upstream automation bridge withholds — streaming text, reasoning, tool + * calls, tool results, and plans — is published here. + */ + const publishEvent = (record: SessionRecord, event: SessionEvent): void => { + switch (event.type) { + case 'assistant/chunk': { + const { turn, step, chunk } = event.data + const key = stepKey(turn, step) + if (chunk.type === 'text-delta') { + if (chunk.text.length === 0) return + record.streamedText.add(key) + update(record, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chunk.text } }) + } else if (chunk.type === 'reasoning-delta') { + if (chunk.text.length === 0) return + record.streamedReasoning.add(key) + if (publishReasoning) { + update(record, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: chunk.text } }) + } + } + return + } + + case 'assistant/message': { + const { turn, step, message } = event.data + const key = stepKey(turn, step) + // A step that streamed deltas already published this text; only a + // non-streaming route (or a replayed round) reaches the client here. + const emitText = !record.streamedText.has(key) + const emitReasoning = publishReasoning && !record.streamedReasoning.has(key) + for (const block of message.content) { + if (block.type === 'text' && block.text.length > 0 && emitText) { + update(record, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: block.text } }) + } else if (block.type === 'reasoning' && block.text.length > 0 && emitReasoning) { + update(record, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: block.text } }) + } else if (block.type === 'image') { + update(record, { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` }, + }) + } + } + return + } + + case 'tool/call': { + const { callId, name: toolName, arguments: args } = event.data + publishToolCall(record, callId, toolName, parseToolArguments(args)) + return + } + + case 'tool/result': { + const outcome = toolOutcome(event.data.message, event.data.error !== undefined) + if (outcome.callId === undefined) return + record.published.delete(outcome.callId) + update(record, { + sessionUpdate: 'tool_call_update', + toolCallId: outcome.callId, + status: outcome.isError ? 'failed' : 'completed', + content: outcome.content, + rawOutput: event.data.message as unknown, + }) + return + } + + case 'todo/write': { + const entries: PlanEntry[] = event.data.todos.map(todo => ({ + content: todo.content, + // The harness todo list carries no priority; ACP requires one, and a + // flat `medium` is the honest rendering of "unranked". + priority: 'medium', + status: todo.status, + })) + update(record, { sessionUpdate: 'plan', entries }) + return + } + + case 'turn/start': { + // The first turn fixes the mode: from here on the history was produced + // under this composition's tools, and a later swap would leave logged + // tool calls the new composition cannot make. Republishing the picker + // shrunk to the mode in force is how the client learns that — said once, + // because every later turn would say the same thing. + if (presets === undefined || record.presetLockPublished) return + record.presetLockPublished = true + // Locked is asserted rather than read: whether this very event is + // already in `session.events` is the session's business, not the + // bridge's, and the answer here is known. + void presetOptions(record, true).then(configOptions => { + if (closed || configOptions.length === 0) return + if (sessions.get(record.agent.session.id) !== record) return + update(record, { sessionUpdate: 'config_option_update', configOptions }) + }, (error: unknown) => { + logger.warn(`acp: failed to publish the fixed agent preset: ${String(error)}`) + }) + return + } + + case 'step/end': { + const key = stepKey(event.data.turn, event.data.step) + record.streamedText.delete(key) + record.streamedReasoning.delete(key) + return + } + + case 'turn/end': { + record.streamedText.clear() + record.streamedReasoning.clear() + record.published.clear() + return + } + + default: + // Every other harness event is internal bookkeeping with no ACP + // counterpart; a client reconstructs round structure from the updates + // above. + return + } + } + + /** + * Republish a resumed session's history so the client renders the + * conversation it is reopening. + * + * Replay is the client's only source for a loaded session: `session/load` + * returns no transcript, so everything the IDE shows above the composer comes + * from these updates. What is withheld is what would double up or lie — + * `assistant/chunk` deltas are already committed into `assistant/message`, + * and `turn/start` would announce a mode lock the load response itself + * carries. Non-user `user/message` entries (plugin reminders, tool feedback) + * are agent-internal input, not something the user typed. + * @param record - the resumed session, already registered. + */ + const replayHistory = (record: SessionRecord): void => { + for (const event of record.agent.session.events) { + try { + if (event.type === 'user/message') { + if (event.data.source.kind !== 'user') continue + for (const block of event.data.content) { + if (block.type !== 'text' || block.text.length === 0) continue + update(record, { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: block.text } }) + } + continue + } + if (event.type === 'assistant/chunk' || event.type === 'turn/start') continue + publishEvent(record, event) + } catch (error: unknown) { + // One unrenderable historical event costs its card, not the reopened + // conversation. + logger.warn(`acp: failed to replay ${event.type}: ${String(error)}`) + } + } + } + + ctx.on('session/event', (session, event: SessionEvent) => { + const record = sessions.get(session.header.id) + if (record === undefined || record.agent.session !== session) return + try { + publishEvent(record, event) + } catch (error: unknown) { + // A presentation-mapping failure must never break turn settlement below: + // the client loses one card, not the answer. + logger.warn(`acp: failed to publish ${event.type}: ${String(error)}`) + } finally { + const inflight = record.inflight + if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { + if (event.data.reason.kind === 'error') { + // Model failures surface immediately as prompt errors; ordinary + // endings wait for whole-agent idle below. + record.inflight = undefined + rejectFromError(inflight, event.data.reason) + } else { + inflight.endReason = event.data.reason + } + } + } + }) + + ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => { + const record = ownedRecord(agent) + const inflight = record?.inflight + if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn + }) + + ctx.on('agent/error', ({ agent, turn, error }) => { + const record = ownedRecord(agent) + const inflight = record?.inflight + if (record === undefined || inflight === undefined || inflight.turn === turn) return + record.inflight = undefined + inflight.reject(internalError(`turn failed: ${errorChain(error)}`)) + }) + + // Permission requests reach the client attached to a tool call it has already + // been shown. The harness appends `tool/call` before the scheduler prepares + // the call (agent-loop `tool-calls.ts`), so the card exists by the time this + // runs; `publishToolCall` re-announces it anyway rather than trusting that + // ordering, because a permission prompt against an unknown call id is exactly + // the failure that leaves an IDE waiting for a card that never arrives. + ctx.on('approval/request', (request, next) => { + const record = ownedRecord(request.agent) + if (record === undefined || request.callId === undefined) return next() + const callId = request.callId + const published = publishToolCall(record, callId, request.toolName, {}) + const toolCall: ToolCallUpdate = { + toolCallId: callId, + title: published.title, + kind: toolKind(published.title), + status: 'pending', + rawInput: published.rawInput, + } + return conn.requestPermission({ + sessionId: record.agent.session.id, + toolCall, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, + ], + }).then(({ outcome }) => { + if (outcome.outcome === 'cancelled') return 'cancelled' + if (outcome.optionId !== 'allow-once') return 'rejected' + // Granted: the call is about to run, so move the card out of `pending`. + // `tool/result` settles it to completed or failed. + update(record, { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'in_progress' }) + return 'allowed-once' + }) + }) + + const makeAgent = (connection: AgentSideConnection): AcpAgent => { + conn = connection + return { + initialize(_params: InitializeRequest): Promise { + // Single-version agent: the spec's "same version if supported, else + // the latest supported" both resolve to this server's one version. + return Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'dsh-acp-ide', version: '0.0.1' }, + agentCapabilities: { + // Reopening a conversation is the IDE's normal case, and the + // harness persists every session it runs: without this, a client + // has no way to say "continue that one" and starts a blank session + // whose history and mode are gone. + loadSession: true, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + }, + authMethods: [], + }) + }, + + authenticate(_params: AuthenticateRequest): Promise { + return Promise.resolve() + }, + + async newSession(params: NewSessionRequest): Promise { + assertOpen() + validateSessionParams(params) + const sessionId = SessionId(randomUUID()) + // Resolved before creation so the session header records the exact id + // that was composed, not the alias `undefined` stands for: the roster's + // default is a hot-reloaded setting, and a resumed session must rebuild + // the composition its turns actually ran under. + const composed = presets === undefined ? undefined : (await presets.resolve()).id + const handle = await agents.create({ + sessionId, + meta: { cwd: params.cwd, ...composed === undefined ? {} : { agentPreset: composed } }, + agentOptions: agentOptions(ctx, config), + // Composition belongs in `setup`, which the factory awaits before the + // agent is published: a preset that fails to mount rolls the whole + // creation back rather than yielding a session on the empty global + // tool layer. + ...presets === undefined || composed === undefined ? {} : { + setup: async (agentCtx: Context) => void await presets.mount(agentCtx, composed), + }, + }) + /* v8 ignore next 4 -- a real stdio close can race an in-flight create. */ + if (closed) { + await handle.dispose() + throw internalError('connection closed during session/new') + } + const record: SessionRecord = { + agent: handle.agent, + dispose: () => handle.dispose(), + streamedText: new Set(), + streamedReasoning: new Set(), + published: new Map(), + presetId: composed, + presetSwitch: undefined, + presetLockPublished: false, + inflight: undefined, + } + sessions.set(sessionId, record) + // Carried by the response rather than announced afterwards: a client + // registers its update handler for a session only once `session/new` + // has returned, so anything published before that can be dropped. + const configOptions = await presetOptions(record, false) + return { sessionId, ...configOptions.length === 0 ? {} : { configOptions } } + }, + + /** + * Reopen a persisted session: resume its agent and replay its history. + * + * The session is rebuilt from storage under the preset its turns actually + * ran on ({@link resolveSessionPreset} reads the log, not just the + * creation header), because a resumed conversation's tool calls have to + * stay ones the mounted composition can still make. That also settles the + * mode picker: a session with turns comes back locked, exactly as it was + * when the client last saw it. + * + * An unknown id is refused with an invalid-parameter error rather than + * silently answered with an empty session, so a client can fall back to + * `session/new` and know that is what happened. + */ + async loadSession(params: LoadSessionRequest): Promise { + assertOpen() + validateSessionParams(params) + const sessionId = SessionId(params.sessionId) + // Already live — a second load of the same session must not resume a + // rival agent on top of the running one. Replaying is still right: the + // client asked for the transcript. + const live = sessions.get(sessionId) + if (live !== undefined) { + replayHistory(live) + const configOptions = await presetOptions(live) + return configOptions.length === 0 ? {} : { configOptions } + } + + const archive = ctx.get('sessionPersistence') as SessionArchive | undefined + if (archive === undefined) throw invalidParams('this deployment persists no sessions') + // Absent, unreadable, and written-by-an-incompatible-format all mean the + // same thing to a client — this conversation cannot be reopened — and + // saying so as an invalid parameter is what lets it fall back to + // `session/new` knowing why. + const inspected = await archive.inspect(sessionId).catch((error: unknown) => { + throw invalidParams(`cannot load session ${sessionId}: ${errorChain(error)}`) + }) + if (inspected.meta.cwd !== params.cwd) { + throw invalidParams( + `session ${sessionId} belongs to "${inspected.meta.cwd ?? '(none)'}", not "${params.cwd}"`, + ) + } + const composed = presets === undefined + ? undefined + : resolveSessionPreset({ header: inspected.meta, events: inspected.events }) + const handle = await agents.resume({ + resumeSessionId: sessionId, + agentOptions: agentOptions(ctx, config), + ...presets === undefined || composed === undefined ? {} : { + setup: async (agentCtx: Context) => void await presets.mount(agentCtx, composed), + }, + }) + /* v8 ignore next 4 -- a real stdio close can race an in-flight resume. */ + if (closed) { + await handle.dispose() + throw internalError('connection closed during session/load') + } + const record: SessionRecord = { + agent: handle.agent, + dispose: () => handle.dispose(), + streamedText: new Set(), + streamedReasoning: new Set(), + published: new Map(), + presetId: composed, + presetSwitch: undefined, + // A session with turns is already locked, and the response below says + // so; only a session reopened before its first turn can still be + // told, by the `turn/start` that starts it. + presetLockPublished: !sessionBlank(handle.agent.session), + inflight: undefined, + } + sessions.set(sessionId, record) + replayHistory(record) + const configOptions = await presetOptions(record) + return configOptions.length === 0 ? {} : { configOptions } + }, + + /** + * Choose the session's mode. + * + * Refused once the conversation has started, for the same reason the + * picker is down to one entry by then: the turns already logged ran on the + * current preset's tools. + */ + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + assertOpen() + const record = requireSession(SessionId(params.sessionId)) + if (params.configId !== PRESET_CONFIG_ID) { + throw invalidParams(`unknown config option: ${params.configId}`) + } + if (presets === undefined) throw invalidParams('this deployment offers no modes') + if (typeof params.value !== 'string') { + throw invalidParams(`"${PRESET_CONFIG_ID}" is a select option, not a boolean`) + } + const id = params.value + const swap = async (): Promise => { + const target = (await presets.list()).find(preset => preset.id === id) + if (target === undefined) throw invalidParams(`unknown mode: ${id}`) + if (target.broken !== undefined) { + throw invalidParams(`mode "${id}" cannot be used: ${target.broken}`) + } + // Asking for the mode already in force is not a switch, so it is + // answered even on a locked session: it changes nothing either way. + if (record.presetId === target.id) return await presetOptions(record) + // Re-read inside the queue: a switch ahead of this one may have run, + // and a conversation may have started, since this request arrived. + if (!sessionBlank(record.agent.session)) throw invalidParams(PRESET_LOCKED) + try { + const preset = await presets.recompose(record.agent.ctx, target.id) + // Logged only after the swap committed, and appended rather than + // written back into the creation header: every later turn runs under + // this composition, so a resumed session must rebuild it rather than + // the one the session was created with. + record.agent.session.append('agent-preset/selected', { agentPreset: preset.id }) + record.presetId = preset.id + } catch (error: unknown) { + // A rejected recompose leaves the previous composition mounted, so + // the session stays usable and the user can pick again. + throw internalError(`failed to switch to "${id}": ${errorChain(error)}`) + } + return await presetOptions(record) + } + const queued = record.presetSwitch ?? Promise.resolve() + const turn = queued.then(swap) + record.presetSwitch = turn.then(() => undefined, () => undefined) + return { configOptions: await turn } + }, + + async prompt(params: PromptRequest): Promise { + assertOpen() + const record = requireSession(SessionId(params.sessionId)) + if (record.inflight !== undefined) { + throw invalidParams('a prompt is already in flight for this session') + } + if (promptHasUnsupportedContent(params.prompt)) { + throw invalidParams('only text and resource_link prompt content is supported') + } + const text = acpPromptToText(params.prompt) + if (text.trim().length === 0) throw invalidParams('empty prompt') + + // Not driving a retired agent is this bridge's contract: an + // agent-loop-only reload disposes the loop's agents while the bridge + // record survives, so validate the record against the live registry + // before sending — a disposed machine would accept the item silently. + if (ctx.agents.get(record.agent.id) !== record.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } + const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + const stopReason = await new Promise((resolve, reject) => { + // Arm the slot before followup() so a listener-driven synchronous + // turn cannot slip past correlation; a synchronous followup() + // failure (invalid input) must free the slot again or the session + // would reject every later prompt as already in flight. + const inflight: NonNullable = { + resolve, reject, messageId: message.id, turn: undefined, endReason: undefined, + } + record.inflight = inflight + try { + record.agent.followup(message) + /* v8 ignore start -- future-proofing guard, see above */ + } catch (error: unknown) { + record.inflight = undefined + const detail = error instanceof Error ? error.message : String(error) + throw internalError(`prompt was not queued: ${detail}`) + } + /* v8 ignore stop */ + // Settlement waits for whole-agent idle: a correlated turn/end arms + // `endReason`, while a turnless slot (admission discarded the + // prompt) stays cancelled. Other producers may run further turns + // before quiescence; the prompt settles only when the agent stops. + void record.agent.whenIdle().then(() => { + if (record.inflight !== inflight) return + record.inflight = undefined + const end = inflight.endReason + if (end === undefined) { + inflight.resolve('cancelled') + } else { + // Token-limit and other non-terminal endings are not prompt-level + // stop reasons; only normal quiescence reports end_turn. + inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) + } + }) + }) + return { stopReason } + }, + + cancel(params: CancelNotification): Promise { + const record = sessions.get(SessionId(params.sessionId)) + if (record === undefined) return Promise.resolve() + record.agent.cancel({ kind: 'user' }) + settlePrompt(record, 'cancelled') + return Promise.resolve() + }, + } + } + + /* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */ + const stream: Stream = config.stream ?? ndJsonStream( + Writable.toWeb(process.stdout) as WritableStream, + Readable.toWeb(process.stdin) as ReadableStream, + ) + conn = new AgentSideConnection(makeAgent, stream) + + let quiescing: Promise | undefined + const quiesce = (): Promise => { + if (quiescing !== undefined) return quiescing + closed = true + const records = [...sessions.values()] + sessions.clear() + // Stop the bridge's own work before any await: a descendant drain can block + // on persistence or scoped cleanup, and the top-level agents must not keep + // running model and tool calls for its whole duration. + for (const record of records) { + record.agent.cancel({ kind: 'user' }) + settlePrompt(record, 'cancelled') + } + quiescing = (async () => { + // Continuable subagents outlive the turn that started them, and their + // Activations own descendant teardown. Drain only these sessions' forests + // child-first BEFORE disposing the top-level agents, so no descendant is + // left holding a runtime its owner already released. + const subagents = ctx.get('subagents') as ContinuableDrain | undefined + if (subagents !== undefined) { + try { + await subagents.drainContinuableDescendants(records.map(record => record.agent)) + } catch (error: unknown) { + logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) + } + } + const disposals = await Promise.allSettled(records.map(record => record.dispose())) + const failures: unknown[] = [] + for (const result of disposals) { + if (result.status === 'rejected') failures.push(result.reason as unknown) + } + if (failures.length > 0) { + const detail = failures.map(failure => errorChain(failure)).join('; ') + throw new AggregateError( + failures, + `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, + ) + } + })() + return quiescing + } + + /* v8 ignore start -- production transport rejection and teardown failure. */ + void conn.closed + .catch((error: unknown) => { + logger.warn(`acp: connection closed with an error: ${String(error)}`) + }) + .then(quiesce) + .catch((error: unknown) => { + logger.warn(`acp: connection-close teardown failed: ${String(error)}`) + }) + /* v8 ignore stop */ + + ctx.effect(() => quiesce, 'acp.connection') +} + +/** + * Build per-agent options without assigning absent optional fields. + * + * Plugin config wins where it is set; anything it leaves open comes from the + * deployment's default selection, read HERE rather than captured at `apply` so + * that a model chosen in the harness's settings after this process started + * takes effect on the next session. + * @param ctx - the plugin context, source of the optional default-model service. + * @param config - ACP provider/model configuration. + * @returns the resolved fields only. + */ +function agentOptions(ctx: Context, config: AcpConfig): { provider?: string; model?: string } { + const fallback = (ctx.get('agentDefaultModel') as DefaultModelSource | undefined)?.currentSelection() + const provider = config.provider ?? fallback?.provider + const model = config.model ?? fallback?.model + return { + ...provider !== undefined ? { provider } : {}, + ...model !== undefined ? { model } : {}, + } +} + +/** Reject session features outside this bridge's contract. */ +function validateSessionParams(params: NewSessionRequest | LoadSessionRequest): void { + if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`) + if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) { + throw invalidParams('additionalDirectories is not supported') + } + if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported') +} diff --git a/packages/dsh-acp/src/codec.ts b/packages/dsh-acp/src/codec.ts new file mode 100644 index 0000000000..ea086993b5 --- /dev/null +++ b/packages/dsh-acp/src/codec.ts @@ -0,0 +1,70 @@ +/** + * Pure translation between the harness lifecycle and the ACP wire. + * + * Forked verbatim from `@deepseek-ai/dsh-acp` + * (deepseek-harness/packages/acp/acp/src/codec.ts, MIT). + * Copyright (c) DeepSeek. See ../NOTICE.md. + * + * @module @bitfun/dsh-acp/codec + */ + +import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk' +import type { TurnEndReason } from '@deepseek-ai/dsh-session' + +/** + * Map a harness turn ending to ACP's terminal reason vocabulary. + * @param reason - harness turn outcome. + * @returns the closest legal ACP stop reason. + */ +export function turnEndToStopReason(reason: TurnEndReason): StopReason { + switch (reason.kind) { + case 'completed': + return 'end_turn' + case 'max-tokens': + return 'max_tokens' + // `cancelled` is reserved for explicit client cancellation (`session/cancel`) + // and disposal, both settled out of band; a turn aborted by a hook or + // another owner is ordinary quiescence and reports `end_turn`. + case 'aborted': + return 'end_turn' + case 'interrupted': + return 'cancelled' + case 'blocked': + case 'error': + return 'end_turn' + default: + return 'end_turn' + } +} + +/** + * Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate + * verbatim; resource links become explicit textual references so a baseline + * client can point at files without the bridge silently dropping that context. + * @param prompt - supported ACP prompt blocks. + * @returns text in wire order, with resource links rendered as bracketed references. + */ +export function acpPromptToText(prompt: readonly AcpContentBlock[]): string { + return prompt.flatMap((block): string[] => { + switch (block.type) { + case 'text': + return [block.text] + case 'resource_link': + return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`] + default: + return [] + } + }).join('') +} + +/** + * Whether a prompt carries content beyond the ACP baseline. The spec requires + * every agent to accept `text` and `resource_link`; richer inline payloads + * (image, audio, embedded resource) are optional capabilities this bridge does + * not advertise, so they are rejected rather than silently dropped. + * @param prompt - ACP prompt blocks to inspect. + * @returns `true` when any block is neither `text` nor `resource_link`. + */ +export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean { + return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link') +} diff --git a/packages/dsh-acp/src/tool-view.ts b/packages/dsh-acp/src/tool-view.ts new file mode 100644 index 0000000000..a011fdabd2 --- /dev/null +++ b/packages/dsh-acp/src/tool-view.ts @@ -0,0 +1,159 @@ +/** + * Presentation mapping from harness tool vocabulary to the ACP tool-call shape + * an IDE renders. This is the part `@deepseek-ai/dsh-acp` deliberately omits: + * its wire is automation-only, so it publishes no tool identity at all. + * + * @module @bitfun/dsh-acp/tool-view + */ + +import type { ToolCallContent, ToolCallLocation, ToolKind } from '@agentclientprotocol/sdk' +import type { ContentBlock, ToolResultMessage } from '@deepseek-ai/dsh-llm' + +/** + * Harness tool name to the ACP kind vocabulary. Exact names only: a client's + * icon and grouping should never hinge on a substring accident, and an + * unrecognized tool is honestly `other` rather than mis-labelled. + */ +const TOOL_KINDS: Readonly> = { + ask_user_question: 'other', + bash: 'execute', + create_goal: 'other', + edit: 'edit', + exit_plan_mode: 'switch_mode', + get_goal: 'other', + glob: 'search', + grep: 'search', + interrupt_agent: 'other', + job_kill: 'other', + job_list: 'other', + job_output: 'other', + list_agents: 'other', + lsp: 'search', + pwsh: 'execute', + ralph: 'other', + read: 'read', + read_image: 'read', + report: 'other', + run_code: 'execute', + send_message: 'other', + session_event_read: 'search', + session_event_search: 'search', + session_event_trace: 'search', + session_search: 'search', + session_trace: 'search', + skill: 'other', + str_replace_editor: 'edit', + subagent: 'other', + subagent_fork: 'other', + terminal_close: 'execute', + terminal_list: 'other', + terminal_open: 'execute', + terminal_read: 'read', + terminal_send: 'execute', + terminal_signal: 'execute', + todo_write: 'other', + update_goal: 'other', + web_fetch: 'fetch', + web_search: 'search', + workflow: 'other', + write: 'edit', +} + +/** + * Classify a harness tool for the client's tool card. + * @param name - the harness tool name as registered with `ctx.tools`. + * @returns the ACP kind, defaulting to `other` for tools this bridge has not seen. + */ +export function toolKind(name: string): ToolKind { + return TOOL_KINDS[name] ?? 'other' +} + +/** + * Decode the model's raw argument string. The harness carries arguments as the + * provider emitted them, so a truncated or malformed call must still produce a + * card: an unparseable payload is surfaced as the literal text instead of + * failing the update. + * @param args - the raw JSON string from `tool/call`. + * @returns the parsed object, or the original string when it is not JSON. + */ +export function parseToolArguments(args: string): unknown { + if (args.length === 0) return {} + try { + return JSON.parse(args) + } catch { + return args + } +} + +/** Argument keys, in priority order, that name the file a tool acts on. */ +const PATH_KEYS = ['file_path', 'path', 'notebook_path', 'filePath'] + +/** + * Extract the file locations a tool call touches so the client can offer + * "follow along" navigation. + * @param input - the parsed tool arguments. + * @returns one location when the arguments name a path, otherwise none. + */ +export function toolLocations(input: unknown): ToolCallLocation[] { + if (typeof input !== 'object' || input === null) return [] + const record = input as Record + for (const key of PATH_KEYS) { + const value = record[key] + if (typeof value !== 'string' || value.length === 0) continue + const line = record['offset'] ?? record['line'] + return [{ path: value, ...typeof line === 'number' ? { line } : {} }] + } + return [] +} + +/** The harness tool-result payload a client can render, lifted out of the message. */ +export interface ToolOutcome { + /** The call this result settles, taken from the result block or the message source. */ + callId: string | undefined + /** Renderable result blocks in wire order. */ + content: ToolCallContent[] + /** Whether the tool reported failure, from the result block or the event's error field. */ + isError: boolean +} + +/** + * Render one harness content block for a tool card. + * @param block - a block from the tool-result payload. + * @returns the ACP content entry, or undefined for blocks with no client rendering. + */ +function renderBlock(block: ContentBlock): ToolCallContent | undefined { + switch (block.type) { + case 'text': + return { type: 'content', content: { type: 'text', text: block.text } } + case 'reasoning': + return { type: 'content', content: { type: 'text', text: block.text } } + case 'image': + return { + type: 'content', + content: { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` }, + } + default: + return undefined + } +} + +/** + * Lift the renderable outcome out of a harness `tool/result` payload. + * @param message - the tool-result message the harness appended. + * @param hasError - whether the event carried a structured error alongside the message. + * @returns the call correlation, rendered content, and failure flag. + */ +export function toolOutcome(message: ToolResultMessage, hasError: boolean): ToolOutcome { + const block = message.content.find(entry => entry.type === 'tool-result') + const content = block === undefined + ? [] + : block.content.flatMap(entry => { + const rendered = renderBlock(entry) + return rendered === undefined ? [] : [rendered] + }) + return { + callId: block?.toolCallId ?? message.source.callId, + content, + isError: hasError || block?.isError === true, + } +} diff --git a/packages/dsh-acp/tests/approval-order.spec.ts b/packages/dsh-acp/tests/approval-order.spec.ts new file mode 100644 index 0000000000..be72b24bc4 --- /dev/null +++ b/packages/dsh-acp/tests/approval-order.spec.ts @@ -0,0 +1,113 @@ +/** + * A permission prompt must never reference a tool call the client has not been + * shown — that mismatch is what leaves an IDE waiting for a card that never + * arrives, and then for a turn that never settles. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import { makeBridgeHarness, waitForEvents, type BridgeHarness, type OrderedEvent } from './harness.ts' + +/** The tool-call ids each timeline entry refers to, for order assertions. */ +function timeline(events: readonly OrderedEvent[]): string[] { + return events.map((event) => { + if (event.kind === 'permission') return `permission:${event.request.toolCall.toolCallId}` + if (event.update.sessionUpdate === 'tool_call') return `tool_call:${event.update.toolCallId}` + if (event.update.sessionUpdate === 'tool_call_update') { + return `tool_call_update:${event.update.toolCallId}:${event.update.status ?? ''}` + } + return event.update.sessionUpdate + }) +} + +describe('ACP approval ordering', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + /** Open a session with the approval waterfall mounted. */ + async function openSession(): Promise { + harness = await makeBridgeHarness() + await harness.ctx.plugin(ApprovalService) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId)) + if (agent === undefined) throw new Error('missing agent') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('step/start', { turn: 1, step: 0 }) + return agent + } + + it('shows the tool card before asking permission for it', async () => { + const agent = await openSession() + const callId = CallId('call-approve') + // The agent loop appends tool/call before the scheduler prepares the call, + // so this is the real production order. + agent.session.append('tool/call', { + turn: 1, step: 0, callId, name: 'bash', arguments: JSON.stringify({ command: 'rm -rf build' }), + }) + await waitForEvents(harness!, 1) + harness!.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + + await expect(harness!.ctx.approval.request({ agent, toolName: 'bash', callId })).resolves.toBe('allowed-once') + + expect(timeline(harness!.orderedEvents)).toEqual([ + 'tool_call:call-approve', + 'permission:call-approve', + 'tool_call_update:call-approve:in_progress', + ]) + expect(harness!.permissionRequests[0]).toMatchObject({ + sessionId: agent.session.id, + toolCall: { toolCallId: 'call-approve', title: 'bash', kind: 'execute', status: 'pending' }, + options: [ + { optionId: 'allow-once', kind: 'allow_once' }, + { optionId: 'reject-once', kind: 'reject_once' }, + ], + }) + expect(harness!.permissionRequests[0]?.toolCall.rawInput).toEqual({ command: 'rm -rf build' }) + }) + + it('announces a call the client was never told about before asking', async () => { + const agent = await openSession() + harness!.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } }) + + await expect(harness!.ctx.approval.request({ agent, toolName: 'write', callId: CallId('call-unseen') })) + .resolves.toBe('rejected') + + // Rejected calls stay pending: the harness reports no tool/result for them. + expect(timeline(harness!.orderedEvents)).toEqual(['tool_call:call-unseen', 'permission:call-unseen']) + expect(harness!.updates[0]).toMatchObject({ + sessionUpdate: 'tool_call', toolCallId: 'call-unseen', title: 'write', kind: 'edit', status: 'pending', + }) + }) + + it('announces the call once across the event and the approval hook', async () => { + const agent = await openSession() + const callId = CallId('call-once') + agent.session.append('tool/call', { + turn: 1, step: 0, callId, name: 'edit', arguments: JSON.stringify({ file_path: '/repo/a.ts' }), + }) + await waitForEvents(harness!, 1) + harness!.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + await harness!.ctx.approval.request({ agent, toolName: 'edit', callId }) + + expect(timeline(harness!.orderedEvents).filter(entry => entry.startsWith('tool_call:'))) + .toEqual(['tool_call:call-once']) + // The re-announcement must not overwrite the arguments the event carried. + expect(harness!.permissionRequests[0]?.toolCall.rawInput).toEqual({ file_path: '/repo/a.ts' }) + }) + + it('leaves foreign and identity-less requests to the rest of the waterfall', async () => { + const agent = await openSession() + await expect(harness!.ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('unavailable') + expect(harness!.permissionRequests).toHaveLength(0) + expect(harness!.updates).toHaveLength(0) + }) +}) diff --git a/packages/dsh-acp/tests/fixtures/plugins/contribute.js b/packages/dsh-acp/tests/fixtures/plugins/contribute.js new file mode 100644 index 0000000000..20157bb599 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/plugins/contribute.js @@ -0,0 +1,17 @@ +// A preset row: registers one model-facing tool named from config, so a test +// can read a session's composition off its tool list. +// +// Import-free on purpose — the Loader resolves a composition row through Node's +// ESM resolver, which cannot see this package's TypeScript sources. +export const name = 'contribute' +export const inject = ['tools'] + +export function apply(ctx, config) { + ctx.effect(() => ctx.tools.register({ + name: config.tool, + description: `fixture tool ${config.tool}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }, + execute: () => Promise.resolve(config.tool), + })) +} diff --git a/packages/dsh-acp/tests/fixtures/presets/alpha/agent.cordis.yml b/packages/dsh-acp/tests/fixtures/presets/alpha/agent.cordis.yml new file mode 100644 index 0000000000..a614459c27 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/alpha/agent.cordis.yml @@ -0,0 +1,5 @@ +# The default fixture mode: one tool, named after the preset. +- id: alpha-tool + name: ../../plugins/contribute.js + config: + tool: alpha diff --git a/packages/dsh-acp/tests/fixtures/presets/alpha/preset.yml b/packages/dsh-acp/tests/fixtures/presets/alpha/preset.yml new file mode 100644 index 0000000000..baad117978 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/alpha/preset.yml @@ -0,0 +1,3 @@ +name: Alpha mode +description: the fixture default +order: 1 diff --git a/packages/dsh-acp/tests/fixtures/presets/beta/agent.cordis.yml b/packages/dsh-acp/tests/fixtures/presets/beta/agent.cordis.yml new file mode 100644 index 0000000000..f863e47626 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/beta/agent.cordis.yml @@ -0,0 +1,5 @@ +# The mode a test switches TO: a disjoint tool set, so a recompose is visible. +- id: beta-tool + name: ../../plugins/contribute.js + config: + tool: beta diff --git a/packages/dsh-acp/tests/fixtures/presets/beta/preset.yml b/packages/dsh-acp/tests/fixtures/presets/beta/preset.yml new file mode 100644 index 0000000000..fd31deadb2 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/beta/preset.yml @@ -0,0 +1,3 @@ +name: Beta mode +description: the fixture alternative +order: 2 diff --git a/packages/dsh-acp/tests/fixtures/presets/broken/preset.yml b/packages/dsh-acp/tests/fixtures/presets/broken/preset.yml new file mode 100644 index 0000000000..5e40795aeb --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/broken/preset.yml @@ -0,0 +1,6 @@ +# A roster row the deployment must fix: the directory occupies the id but +# carries no `agent.cordis.yml`, so discovery reports it broken. It exists to +# prove the mode picker withholds a choice every attempt would refuse. +name: Broken mode +description: the fixture nobody can compose +order: 3 diff --git a/packages/dsh-acp/tests/harness.ts b/packages/dsh-acp/tests/harness.ts new file mode 100644 index 0000000000..7ec8a50c8f --- /dev/null +++ b/packages/dsh-acp/tests/harness.ts @@ -0,0 +1,281 @@ +/** + * In-memory ACP transport fixture over the real agent factory and loop. + * + * Adapted from `deepseek-harness/packages/acp/acp/tests/harness.ts` (MIT, + * copyright (c) DeepSeek); see ../NOTICE.md. The only substantive change is the + * plugin under test — `../src/bridge.ts` instead of `@deepseek-ai/dsh-acp` — + * plus `orderedEvents`, which records session updates and permission requests + * on one timeline so a test can assert their relative order. + */ + +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import AgentPresets from '@deepseek-ai/dsh-agent-presets' +import { + ClientSideConnection, + ndJsonStream, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type Stream, +} from '@agentclientprotocol/sdk' +import { type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as AcpPlugin from '../src/bridge.ts' +import type { AcpConfig } from '../src/bridge.ts' + +/** Scripted adapter for protocol tests. */ +class MockAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + private readonly script: (StreamChunk[] | 'hang')[] + + constructor(script: (StreamChunk[] | 'hang')[]) { + super() + this.script = script + } + + override providerInfo(provider: string) { + if (provider !== 'mock') throw new Error(`MockAdapter: unknown provider ${provider}`) + return { id: 'mock', name: 'Mock' } + } + + override listModels(provider: string) { + return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : []) + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.script.shift() + if (entry === undefined) throw new Error('MockAdapter: script exhausted') + if (entry === 'hang') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + if (options.signal?.aborted) { + reject(new Error('aborted')) + return + } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } + for (const chunk of entry) { + if (options.signal?.aborted) throw new Error('aborted') + yield chunk + } + } +} + +/** Scripted text response ending in a clean stop. */ +export function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })), + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 5, outputTokens: text.length } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** + * The fixture preset roster: `alpha` and `beta`, each contributing one tool + * named after itself, so a test can read a session's composition off its tool + * list and see a recompose replace it. + */ +export const PRESET_FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'presets') + +/** Scripted response that fails after publishing an uncommitted partial chunk. */ +export function errorResponse(message: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'partial' }, + { type: 'finish', reason: { kind: 'error', failure: { message, code: 'PROVIDER_ERROR' } } }, + ] +} + +export type CapturedUpdate = SessionNotification['update'] + +/** + * One entry on the client-observed timeline. Session updates and permission + * requests arrive over the same connection, so recording them together is the + * only way to assert that a tool card precedes the prompt that references it. + */ +export type OrderedEvent = + | { kind: 'update'; update: CapturedUpdate } + | { kind: 'permission'; request: RequestPermissionRequest } + +export interface BridgeHarness { + ctx: Context + client: ClientSideConnection + adapter: MockAdapter + updates: CapturedUpdate[] + sessionUpdates: { sessionId: string; update: CapturedUpdate }[] + permissionRequests: RequestPermissionRequest[] + /** Updates and permission requests interleaved in arrival order. */ + orderedEvents: OrderedEvent[] + onPermission: (request: RequestPermissionRequest) => RequestPermissionResponse + closeClientTransport: () => Promise + acpFiber: Awaited> + /** The AgentLoop fiber, so a test can reload the loop out from under the bridge. */ + loopFiber: Awaited> + dispose: () => Promise +} + +type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined } + +/** Build the bridge and a connected SDK client over cross-wired byte streams. */ +export async function makeBridgeHarness(options: { + script?: (StreamChunk[] | 'hang')[] + config?: AcpConfigOverrides + persona?: string + /** + * Mount the {@link PRESET_FIXTURES} roster with this default, so the bridge + * composes sessions from a preset and offers the modes as a session config + * option. Omitted, no roster is published and the bridge takes its no-modes + * path. + */ + presets?: { default: string } + /** + * Mount durable JSONL persistence under this root, so a session outlives its + * agent and `session/load` has something to resume. Omitted, the deployment + * persists nothing and the bridge takes its no-archive path. + */ + persistenceRoot?: string +} = {}): Promise { + const adapter = new MockAdapter(options.script ?? []) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } }) + // Before the loop, as the app composes it: the coordinator captures a + // session's header from `session/created`, which the first agent emits. + if (options.persistenceRoot !== undefined) { + await ctx.plugin(JsonlSessionPersistence, { root: options.persistenceRoot }) + } + const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + if (options.presets !== undefined) { + // Before the bridge: it captures the roster during `apply`, exactly as the + // app's composition orders them. + ctx.baseUrl = pathToFileURL(PRESET_FIXTURES).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(AgentPresets, { + default: options.presets.default, + roots: [{ path: PRESET_FIXTURES, trust: 'system' as const }], + includeUserRoot: false, + }) + } + + const agentToClient = new TransformStream() + const clientToAgent = new TransformStream() + const clientToAgentWriter = clientToAgent.writable.getWriter() + const clientOutput = new WritableStream({ + write: chunk => clientToAgentWriter.write(chunk), + }) + const agentStream: Stream = ndJsonStream(agentToClient.writable, clientToAgent.readable) + const clientStream: Stream = ndJsonStream(clientOutput, agentToClient.readable) + + const updates: CapturedUpdate[] = [] + const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = [] + const permissionRequests: RequestPermissionRequest[] = [] + const orderedEvents: OrderedEvent[] = [] + const harness: BridgeHarness = { + ctx, + adapter, + updates, + sessionUpdates, + permissionRequests, + orderedEvents, + onPermission: () => ({ outcome: { outcome: 'cancelled' } }), + client: undefined as unknown as ClientSideConnection, + acpFiber: undefined as unknown as BridgeHarness['acpFiber'], + loopFiber, + closeClientTransport: async () => { await clientToAgentWriter.close() }, + dispose: async () => { await ctx.fiber.dispose() }, + } + + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + updates.push(params.update) + sessionUpdates.push({ sessionId: params.sessionId, update: params.update }) + orderedEvents.push({ kind: 'update', update: params.update }) + return Promise.resolve() + }, + requestPermission(params: RequestPermissionRequest): Promise { + permissionRequests.push(params) + orderedEvents.push({ kind: 'permission', request: params }) + return Promise.resolve(harness.onPermission(params)) + }, + }) + + const config = { stream: agentStream, ...options.config } as AcpConfig + if (!(options.config && 'provider' in options.config)) config.provider = 'mock' + if (!(options.config && 'model' in options.config)) config.model = 'mock' + harness.acpFiber = await ctx.plugin({ + name: 'acp-ide-test', + inject: [...AcpPlugin.inject], + apply: (inner: Context) => { AcpPlugin.apply(inner, config) }, + }) + harness.client = new ClientSideConnection(makeClient, clientStream) + return harness +} + +/** + * Wait until the client has observed at least `count` timeline entries. + * @param harness - the fixture whose client timeline is polled. + * @param count - the number of entries to wait for. + */ +export async function waitForEvents(harness: BridgeHarness, count: number): Promise { + const deadline = Date.now() + 2000 + while (harness.orderedEvents.length < count) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${count} client events (saw ${harness.orderedEvents.length})`) + } + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +/** + * Wait until the client has observed `count` session updates of one kind. + * + * `waitForEvents` counts everything the session has produced, so a finished + * prompt already satisfies it. That makes it useless for waiting on a + * notification the bridge publishes off the back of an event rather than as + * part of the prompt reply — the wait returns at once and the assertion races + * the notification. Wait for the notification itself instead. + * @param harness - the fixture whose updates are polled. + * @param kind - the `sessionUpdate` discriminant to count. + * @param count - the number of such updates to wait for. + */ +export async function waitForUpdates( + harness: BridgeHarness, kind: K, count: number, +): Promise { + const deadline = Date.now() + 2000 + while (updatesOfKind(harness, kind).length < count) { + if (Date.now() > deadline) { + throw new Error( + `timed out waiting for ${count} ${kind} update(s) ` + + `(saw ${updatesOfKind(harness, kind).length})`, + ) + } + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +/** + * Collect every session update of one kind that the client has observed. + * @param harness - the fixture to read. + * @param kind - the `sessionUpdate` discriminator to keep. + * @returns the matching updates in arrival order. + */ +export function updatesOfKind( + harness: BridgeHarness, kind: K, +): Extract[] { + return harness.updates.filter( + (update): update is Extract => update.sessionUpdate === kind, + ) +} diff --git a/packages/dsh-acp/tests/lifecycle.spec.ts b/packages/dsh-acp/tests/lifecycle.spec.ts new file mode 100644 index 0000000000..a173ec6152 --- /dev/null +++ b/packages/dsh-acp/tests/lifecycle.spec.ts @@ -0,0 +1,79 @@ +/** + * Regression cover for the upstream machinery this fork keeps verbatim: prompt + * settlement, cancellation, and the single in-flight slot. Where behavior + * intentionally diverges — a streaming client sees deltas, including a failed + * turn's partial text — the test says so. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { SessionId } from '@deepseek-ai/dsh-session' +import { errorResponse, makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' + +/** All published assistant text, concatenated in arrival order. */ +function messageText(harness: BridgeHarness): string { + return harness.updates.flatMap(update => ( + update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [] + )).join('') +} + +describe('ACP prompt lifecycle', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + async function newSession(): Promise { + if (harness === undefined) throw new Error('missing harness') + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + return (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId + } + + it('streams a whole turn and settles it as end_turn', async () => { + harness = await makeBridgeHarness({ script: [textResponse('answer')] }) + const sessionId = await newSession() + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await vi.waitFor(() => { expect(messageText(harness!)).toBe('answer') }) + // Character deltas, not one committed blob: this is the streaming an IDE + // renders live, and the commit must not duplicate it. + expect(harness.updates.filter(update => update.sessionUpdate === 'agent_message_chunk')).toHaveLength(6) + }) + + it('rejects a failed turn but keeps the partial text it already streamed', async () => { + harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] }) + const sessionId = await newSession() + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: provider boom/) + // Deliberate divergence from the automation bridge, which withholds every + // uncommitted chunk: a live client has already shown these characters, and + // the prompt rejection is what tells it the turn failed. + expect(messageText(harness)).toBe('partial') + }) + + it('permits one in-flight prompt and continues the session after a cancel', async () => { + harness = await makeBridgeHarness({ script: ['hang', textResponse('second')] }) + const sessionId = await newSession() + const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] }) + await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] })) + .rejects.toThrow(/already in flight/) + + await harness.client.cancel({ sessionId }) + await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'again' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await vi.waitFor(() => { expect(messageText(harness!)).toContain('second') }) + }) + + it('refuses a prompt for an unknown session', async () => { + harness = await makeBridgeHarness() + await newSession() + await expect(harness.client.prompt({ sessionId: 'nope', prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/unknown session/) + }) +}) diff --git a/packages/dsh-acp/tests/preset-modes.spec.ts b/packages/dsh-acp/tests/preset-modes.spec.ts new file mode 100644 index 0000000000..9f26103090 --- /dev/null +++ b/packages/dsh-acp/tests/preset-modes.spec.ts @@ -0,0 +1,172 @@ +/** + * Modes: the preset roster a session is composed from, offered to the client as + * a `mode` session config option and fixed once the conversation starts. + * + * The switch is driven the way a client's composer picker drives it — through + * `session/set_config_option` — and the result is read off the session's tool + * list rather than off anything the bridge reports about itself. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION, type SessionConfigOption } from '@agentclientprotocol/sdk' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + makeBridgeHarness, textResponse, updatesOfKind, waitForUpdates, type BridgeHarness, +} from './harness.ts' + +/** The tools a session's model can call, i.e. what its preset composed. */ +function toolNames(harness: BridgeHarness, sessionId: string): string[] { + const agent = harness.ctx.agents.get(SessionId(sessionId)) + if (agent === undefined) throw new Error(`no agent for session ${sessionId}`) + return harness.ctx.tools.schemas(agent).map(schema => schema.name).sort() +} + +/** Every preset the session has been switched to, in order. */ +function selections(harness: BridgeHarness, sessionId: string): unknown[] { + const agent = harness.ctx.agents.get(SessionId(sessionId)) + if (agent === undefined) throw new Error(`no agent for session ${sessionId}`) + return agent.session.events + .filter(event => event.type === 'agent-preset/selected') + .map(event => (event as { data: { agentPreset: string } }).data.agentPreset) +} + +/** The one mode option, asserted to be a select so its values can be read. */ +function modeOption(configOptions: SessionConfigOption[] | null | undefined): Extract< + SessionConfigOption, { type: 'select' } +> { + const [option, ...rest] = configOptions ?? [] + expect(rest).toEqual([]) + if (option?.type !== 'select') throw new Error(`expected one select option, got ${JSON.stringify(configOptions)}`) + return option +} + +/** The values a client's picker would list, in roster order. */ +function modeValues(configOptions: SessionConfigOption[] | null | undefined): string[] { + return modeOption(configOptions).options.map(value => ('value' in value ? value.value : value.group)) +} + +describe('agent-preset modes', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + it('composes the default preset and offers the roster with session/new', async () => { + harness = await makeBridgeHarness({ presets: { default: 'alpha' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + expect(toolNames(harness, session.sessionId)).toEqual(['alpha']) + const option = modeOption(session.configOptions) + expect(option.id).toBe('agent-preset') + expect(option.category).toBe('mode') + expect(option.currentValue).toBe('alpha') + // `broken` is withheld: offering it would advertise a choice every attempt + // refuses. Nothing says the mode is fixed yet, because it is not. + expect(option.options).toEqual([ + { value: 'alpha', name: 'Alpha mode', description: 'the fixture default' }, + { value: 'beta', name: 'Beta mode', description: 'the fixture alternative' }, + ]) + expect(option.description).toBeUndefined() + }) + + it('recomposes a blank session, without a model turn', async () => { + harness = await makeBridgeHarness({ presets: { default: 'alpha' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const result = await harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'beta', + }) + + // The exchange is a control, not a turn: the model is never asked. + expect(harness.adapter.requests).toHaveLength(0) + expect(toolNames(harness, sessionId)).toEqual(['beta']) + expect(selections(harness, sessionId)).toEqual(['beta']) + // The response carries the whole set, so the picker re-renders from it. + expect(modeOption(result.configOptions).currentValue).toBe('beta') + expect(modeValues(result.configOptions)).toEqual(['alpha', 'beta']) + }) + + it('answers a repeat of the session\'s own mode without recomposing', async () => { + harness = await makeBridgeHarness({ presets: { default: 'alpha' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const result = await harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'alpha', + }) + + expect(modeOption(result.configOptions).currentValue).toBe('alpha') + expect(selections(harness, sessionId)).toEqual([]) + expect(toolNames(harness, sessionId)).toEqual(['alpha']) + }) + + it('refuses an option, a mode, and a value type it does not have', async () => { + harness = await makeBridgeHarness({ presets: { default: 'alpha' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'thought-level', value: 'high', + })).rejects.toThrow(/unknown config option: thought-level/) + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'gamma', + })).rejects.toThrow(/unknown mode: gamma/) + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'broken', + })).rejects.toThrow(/cannot be used/) + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', type: 'boolean', value: true, + })).rejects.toThrow(/select option, not a boolean/) + + expect(toolNames(harness, sessionId)).toEqual(['alpha']) + expect(selections(harness, sessionId)).toEqual([]) + }) + + it('fixes the mode once the conversation has started, and says so once', async () => { + harness = await makeBridgeHarness({ + presets: { default: 'alpha' }, + script: [textResponse('hi'), textResponse('again')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }] }) + + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'beta', + })).rejects.toThrow(/already started/) + + expect(toolNames(harness, sessionId)).toEqual(['alpha']) + expect(selections(harness, sessionId)).toEqual([]) + // The picker was shrunk to the mode in force, which is how a client learns + // to disable it without knowing anything about presets. + await waitForUpdates(harness, 'config_option_update', 1) + const [locked, ...repeats] = updatesOfKind(harness, 'config_option_update') + expect(modeValues(locked?.configOptions)).toEqual(['alpha']) + expect(modeOption(locked?.configOptions).description).toMatch(/already started/) + + // A second turn does not say it again. + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'more' }] }) + expect(repeats).toEqual([]) + expect(updatesOfKind(harness, 'config_option_update')).toHaveLength(1) + }) + + it('offers nothing when the deployment has no roster', async () => { + harness = await makeBridgeHarness({ script: [textResponse('ok')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + expect(session.configOptions ?? []).toEqual([]) + await expect(harness.client.setSessionConfigOption({ + sessionId: session.sessionId, configId: 'agent-preset', value: 'beta', + })).rejects.toThrow(/offers no modes/) + + // A turn still runs, and still announces nothing about modes. + await harness.client.prompt({ sessionId: session.sessionId, prompt: [{ type: 'text', text: 'hi' }] }) + expect(harness.adapter.requests).toHaveLength(1) + expect(updatesOfKind(harness, 'config_option_update')).toEqual([]) + }) +}) diff --git a/packages/dsh-acp/tests/round-trip.spec.ts b/packages/dsh-acp/tests/round-trip.spec.ts new file mode 100644 index 0000000000..a1fb8a8ea7 --- /dev/null +++ b/packages/dsh-acp/tests/round-trip.spec.ts @@ -0,0 +1,116 @@ +/** + * The whole path in one test: a model asks for a tool, the loop appends + * `tool/call`, the scheduler asks for approval, the tool runs, and the client + * sees a card that opens before the prompt and closes after the result. + * + * The hand-driven specs assert each mapping in isolation; this one is the only + * place the ORDER is a fact about the production loop rather than about how a + * test appended events. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' + +/** One scripted model step that calls `write` and stops for its result. */ +const CALL_WRITE: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { + type: 'block-end', + index: 0, + block: { + type: 'tool-call', + id: CallId('call-write'), + name: 'write', + arguments: JSON.stringify({ file_path: '/repo/notes.md', content: 'hi' }), + }, + }, + { type: 'finish', reason: { kind: 'tool-calls' } }, +] + +describe('ACP tool-call round trip', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + it('opens the card, asks, runs, and closes it — in that order', async () => { + harness = await makeBridgeHarness({ script: [CALL_WRITE, textResponse('written')] }) + await harness.ctx.plugin(ApprovalService) + harness.ctx.tools.register(defineContentToolFixture({ + name: 'write', + description: 'write a file', + parameters: { file_path: { type: 'string' }, content: { type: 'string' } }, + execute: () => Promise.resolve([{ type: 'text', text: 'wrote 2 bytes' }]), + })) + // The production ask path: the scheduler waterfalls `tools/pre-execute` + // before dispatch, and an `ask` decision routes through ctx.approval. + harness.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'ask', reason: 'test policy' })) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'write notes' }] }) + expect(result.stopReason).toBe('end_turn') + + await vi.waitFor(() => { + expect(harness!.orderedEvents.some(event => event.kind === 'update' + && event.update.sessionUpdate === 'tool_call_update' + && event.update.status === 'completed')).toBe(true) + }) + + const trace = harness.orderedEvents.flatMap((event) => { + if (event.kind === 'permission') return [`permission:${event.request.toolCall.toolCallId}`] + const update = event.update + if (update.sessionUpdate === 'tool_call') return [`open:${update.toolCallId}:${update.status ?? ''}`] + if (update.sessionUpdate === 'tool_call_update') return [`${update.status}:${update.toolCallId}`] + return [] + }) + expect(trace).toEqual([ + 'open:call-write:pending', + 'permission:call-write', + 'in_progress:call-write', + 'completed:call-write', + ]) + + const permission = harness.permissionRequests[0] + expect(permission?.toolCall).toMatchObject({ title: 'write', kind: 'edit' }) + expect(permission?.toolCall.rawInput).toEqual({ file_path: '/repo/notes.md', content: 'hi' }) + + const completed = harness.updates.find(update => update.sessionUpdate === 'tool_call_update' + && update.status === 'completed') + expect(completed).toMatchObject({ + content: [{ type: 'content', content: { type: 'text', text: 'wrote 2 bytes' } }], + }) + }) + + it('closes the card as failed when the user rejects the call', async () => { + harness = await makeBridgeHarness({ script: [CALL_WRITE, textResponse('stopped')] }) + await harness.ctx.plugin(ApprovalService) + harness.ctx.tools.register(defineContentToolFixture({ + name: 'write', + description: 'write a file', + parameters: { file_path: { type: 'string' }, content: { type: 'string' } }, + execute: () => Promise.reject(new Error('must not run')), + })) + harness.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'ask', reason: 'test policy' })) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } }) + + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'write notes' }] }) + + await vi.waitFor(() => { + expect(harness!.updates.some(update => update.sessionUpdate === 'tool_call_update')).toBe(true) + }) + const settled = harness.updates.filter(update => update.sessionUpdate === 'tool_call_update') + // A rejection never reaches `in_progress`; the denial arrives as the result. + expect(settled.map(update => update.status)).toEqual(['failed']) + expect(settled[0]).toMatchObject({ toolCallId: 'call-write' }) + }) +}) diff --git a/packages/dsh-acp/tests/session-load.spec.ts b/packages/dsh-acp/tests/session-load.spec.ts new file mode 100644 index 0000000000..5e390fea3f --- /dev/null +++ b/packages/dsh-acp/tests/session-load.spec.ts @@ -0,0 +1,160 @@ +/** + * Reopening a persisted conversation: `session/load` resumes the stored + * session, replays its history to the client, and brings back the mode it ran + * under — the three things an IDE loses when a reopen silently becomes a new + * session. + * + * The conversation under test is seeded OUTSIDE the bridge and then left to go + * cold, which is what a client restart actually looks like from the agent's + * side: nothing about the session is still in memory when the load arrives. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION, RequestError, type SessionConfigOption } from '@agentclientprotocol/sdk' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + makeBridgeHarness, textResponse, updatesOfKind, waitForUpdates, type BridgeHarness, +} from './harness.ts' + +/** The one mode option, asserted to be a select so its values can be read. */ +function modeOption(configOptions: SessionConfigOption[] | null | undefined): Extract< + SessionConfigOption, { type: 'select' } +> { + const [option, ...rest] = configOptions ?? [] + expect(rest).toEqual([]) + if (option?.type !== 'select') throw new Error(`expected one select option, got ${JSON.stringify(configOptions)}`) + return option +} + +/** The text of every update of one kind, in arrival order. */ +function textsOfKind( + harness: BridgeHarness, kind: 'user_message_chunk' | 'agent_message_chunk', +): string[] { + return updatesOfKind(harness, kind) + .map(update => (update.content.type === 'text' ? update.content.text : '')) +} + +/** + * Seed one persisted conversation and let it go cold. + * @param harness - the fixture whose registry and roster compose the agent. + * @param cwd - the working directory the session is created in. + * @param prompt - the user text the scripted model answers. + * @returns the persisted session id, with no live agent left behind. + */ +async function seedColdSession( + harness: BridgeHarness, cwd: string, prompt: string, +): Promise { + const sessionId = SessionId(randomUUID()) + const presets = harness.ctx.get('agentPresets') + if (presets === undefined) throw new Error('the fixture roster is required to seed a session') + const handle = await harness.ctx.agents.create({ + sessionId, + meta: { cwd, agentPreset: 'alpha' }, + agentOptions: { provider: 'mock', model: 'mock' }, + setup: async (agentCtx) => void await presets.mount(agentCtx, 'alpha'), + }) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: prompt }], + source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + // Disposal is what makes this a COLD load: the agent, its session, and the + // loop state are gone, and only the durable log remains. + await handle.dispose() + return sessionId +} + +describe('session/load', () => { + let harness: BridgeHarness | undefined + let root: string | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + }) + + it('advertises the capability', async () => { + harness = await makeBridgeHarness() + const result = await harness.client.initialize({ + protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, + }) + + expect(result.agentCapabilities?.loadSession).toBe(true) + }) + + it('resumes a cold session with its history, mode, and context', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-acp-load-')) + harness = await makeBridgeHarness({ + script: [textResponse('stored answer'), textResponse('second answer')], + presets: { default: 'beta' }, + persistenceRoot: root, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const cwd = process.cwd() + const sessionId = await seedColdSession(harness, cwd, 'stored question') + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + + const loaded = await harness.client.loadSession({ sessionId, cwd, mcpServers: [] }) + + // The transcript the client renders comes only from replay. + await waitForUpdates(harness, 'agent_message_chunk', 1) + expect(textsOfKind(harness, 'user_message_chunk')).toEqual(['stored question']) + expect(textsOfKind(harness, 'agent_message_chunk')).toEqual(['stored answer']) + // The stored preset wins over the roster default (`beta`), and a session + // whose conversation has started comes back with the mode fixed. + const option = modeOption(loaded.configOptions) + expect(option.currentValue).toBe('alpha') + expect(option.options).toEqual([ + { value: 'alpha', name: 'Alpha mode', description: 'the fixture default' }, + ]) + expect(option.description).toContain('already started') + const resumed = harness.ctx.agents.get(SessionId(sessionId)) + expect(resumed).toBeDefined() + expect(harness.ctx.tools.schemas(resumed!).map(schema => schema.name)).toEqual(['alpha']) + + // Context, not just presentation: the next prompt carries the stored turn. + const response = await harness.client.prompt({ + sessionId, prompt: [{ type: 'text', text: 'follow-up' }], + }) + + expect(response.stopReason).toBe('end_turn') + const request = harness.adapter.requests.at(-1) + const sent = JSON.stringify(request?.messages ?? []) + expect(sent).toContain('stored question') + expect(sent).toContain('stored answer') + }) + + it('refuses a session it has never stored', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-acp-load-')) + harness = await makeBridgeHarness({ persistenceRoot: root }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + + // Refused rather than answered with a blank session: a client that is told + // "no such session" can fall back to session/new and know it did. + await expect(harness.client.loadSession({ + sessionId: randomUUID(), cwd: process.cwd(), mcpServers: [], + })).rejects.toThrow(RequestError) + }) + + it('refuses a session stored for another directory', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-acp-load-')) + harness = await makeBridgeHarness({ + script: [textResponse('stored answer')], + presets: { default: 'alpha' }, + persistenceRoot: root, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const sessionId = await seedColdSession(harness, process.cwd(), 'stored question') + + await expect(harness.client.loadSession({ + sessionId, cwd: tmpdir(), mcpServers: [], + })).rejects.toThrow(RequestError) + }) +}) diff --git a/packages/dsh-acp/tests/tool-events.spec.ts b/packages/dsh-acp/tests/tool-events.spec.ts new file mode 100644 index 0000000000..596b72603b --- /dev/null +++ b/packages/dsh-acp/tests/tool-events.spec.ts @@ -0,0 +1,242 @@ +/** The presentation data this fork exists to publish: tool cards, plans, text. */ + +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { CallId, createAssistantMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeBridgeHarness, updatesOfKind, waitForEvents, type BridgeHarness } from './harness.ts' + +/** Surface-eligible events must declare how they enter the ordered surface. */ +const SURFACE = { surfaceOp: 'append' } as const + +/** The provenance every scripted assistant message carries in these tests. */ +const PROVENANCE = { provider: 'mock', model: 'mock' } as const + +describe('ACP session-event publication', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + /** Open a session and hand back its live agent, ready to append events to. */ + async function openSession(config?: { reasoning?: boolean }): Promise { + harness = await makeBridgeHarness(config === undefined ? {} : { config }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId)) + if (agent === undefined) throw new Error('missing agent') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('step/start', { turn: 1, step: 0 }) + return agent + } + + it('publishes a tool call as a pending card and its result as completed', async () => { + const agent = await openSession() + const callId = CallId('call-1') + agent.session.append('tool/call', { + turn: 1, + step: 0, + callId, + name: 'read', + arguments: JSON.stringify({ file_path: '/repo/src/main.ts', offset: 40 }), + }) + agent.session.append('tool/result', { + turn: 1, + step: 0, + message: createToolResultMessage({ + callId, content: [{ type: 'text', text: 'file body' }], isError: false, + }), + }, SURFACE) + await waitForEvents(harness!, 2) + + expect(updatesOfKind(harness!, 'tool_call')[0]).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'read', + kind: 'read', + status: 'pending', + rawInput: { file_path: '/repo/src/main.ts', offset: 40 }, + locations: [{ path: '/repo/src/main.ts', line: 40 }], + }) + expect(updatesOfKind(harness!, 'tool_call_update')[0]).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'file body' } }], + }) + }) + + it('maps each harness tool name to its ACP kind', async () => { + const agent = await openSession() + const cases = [ + { name: 'bash', kind: 'execute' }, + { name: 'edit', kind: 'edit' }, + { name: 'grep', kind: 'search' }, + { name: 'web_fetch', kind: 'fetch' }, + { name: 'todo_write', kind: 'other' }, + { name: 'a_tool_nobody_registered', kind: 'other' }, + ] + cases.forEach((entry, index) => { + agent.session.append('tool/call', { + turn: 1, step: 0, callId: CallId(`call-${index}`), name: entry.name, arguments: '{}', + }) + }) + await waitForEvents(harness!, cases.length) + + expect(updatesOfKind(harness!, 'tool_call').map(update => ({ + name: update.title, kind: update.kind, + }))).toEqual(cases) + }) + + it('reports a failed tool call as failed and carries the raw result', async () => { + const agent = await openSession() + const callId = CallId('call-boom') + agent.session.append('tool/call', { + turn: 1, step: 0, callId, name: 'bash', arguments: JSON.stringify({ command: 'false' }), + }) + agent.session.append('tool/result', { + turn: 1, + step: 0, + message: createToolResultMessage({ + callId, content: [{ type: 'text', text: 'exit status 1' }], isError: true, + }), + error: { name: 'ToolError', code: 'EXIT_STATUS' }, + }, SURFACE) + await waitForEvents(harness!, 2) + + const settled = updatesOfKind(harness!, 'tool_call_update')[0] + expect(settled).toMatchObject({ toolCallId: 'call-boom', status: 'failed' }) + expect(settled?.rawOutput).toMatchObject({ content: [{ type: 'tool-result', toolCallId: 'call-boom' }] }) + }) + + it('keeps a tool call whose arguments are not JSON', async () => { + const agent = await openSession() + agent.session.append('tool/call', { + turn: 1, step: 0, callId: CallId('call-partial'), name: 'bash', arguments: '{"command": "ec', + }) + await waitForEvents(harness!, 1) + + expect(updatesOfKind(harness!, 'tool_call')[0]).toMatchObject({ + toolCallId: 'call-partial', kind: 'execute', rawInput: '{"command": "ec', locations: [], + }) + }) + + it('streams deltas and does not repeat them when the message commits', async () => { + const agent = await openSession() + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }) + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hel' }, + }) + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'lo' }, + }) + agent.session.append('assistant/message', { + turn: 1, + step: 0, + message: createAssistantMessage({ + source: PROVENANCE, + content: [{ type: 'reasoning', text: 'thinking' }, { type: 'text', text: 'hello' }], + }), + }, SURFACE) + await waitForEvents(harness!, 3) + + expect(updatesOfKind(harness!, 'agent_message_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'hel' }, { type: 'text', text: 'lo' }]) + expect(updatesOfKind(harness!, 'agent_thought_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'thinking' }]) + }) + + it('publishes a committed message that never streamed', async () => { + const agent = await openSession() + agent.session.append('assistant/message', { + turn: 1, + step: 0, + message: createAssistantMessage({ source: PROVENANCE, content: [{ type: 'text', text: 'whole answer' }] }), + }, SURFACE) + await waitForEvents(harness!, 1) + + expect(updatesOfKind(harness!, 'agent_message_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'whole answer' }]) + }) + + it('re-publishes committed text for a later step of the same turn', async () => { + const agent = await openSession() + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed' }, + }) + agent.session.append('step/end', { turn: 1, step: 0 }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ source: PROVENANCE, content: [{ type: 'text', text: 'second step' }] }), + }, SURFACE) + await waitForEvents(harness!, 2) + + expect(updatesOfKind(harness!, 'agent_message_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'streamed' }, { type: 'text', text: 'second step' }]) + }) + + it('withholds reasoning when the deployment turns it off', async () => { + const agent = await openSession({ reasoning: false }) + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'private' }, + }) + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'public' }, + }) + agent.session.append('assistant/message', { + turn: 1, + step: 0, + message: createAssistantMessage({ + source: PROVENANCE, + content: [{ type: 'reasoning', text: 'private' }, { type: 'text', text: 'public' }], + }), + }, SURFACE) + await waitForEvents(harness!, 1) + + expect(updatesOfKind(harness!, 'agent_thought_chunk')).toHaveLength(0) + expect(updatesOfKind(harness!, 'agent_message_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'public' }]) + }) + + it('republishes each todo write as a plan', async () => { + const agent = await openSession() + agent.session.append('todo/write', { + todos: [ + { content: 'read the failing test', status: 'completed' }, + { content: 'fix the parser', status: 'in_progress' }, + { content: 'run the suite', status: 'pending' }, + ], + }) + await waitForEvents(harness!, 1) + + expect(updatesOfKind(harness!, 'plan')[0]).toEqual({ + sessionUpdate: 'plan', + entries: [ + { content: 'read the failing test', priority: 'medium', status: 'completed' }, + { content: 'fix the parser', priority: 'medium', status: 'in_progress' }, + { content: 'run the suite', priority: 'medium', status: 'pending' }, + ], + }) + }) + + it('keeps harness bookkeeping events off the wire', async () => { + const agent = await openSession() + agent.session.append('step/end', { turn: 1, step: 0 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + agent.session.append('turn/start', { turn: 2 }) + agent.session.append('step/start', { turn: 2, step: 0 }) + agent.session.append('assistant/chunk', { + turn: 2, step: 0, chunk: { type: 'text-delta', index: 0, text: 'next turn' }, + }) + await waitForEvents(harness!, 1) + + expect(harness!.updates.map(update => update.sessionUpdate)).toEqual(['agent_message_chunk']) + }) +}) diff --git a/packages/dsh-acp/tsconfig.build.json b/packages/dsh-acp/tsconfig.build.json new file mode 100644 index 0000000000..5736159eab --- /dev/null +++ b/packages/dsh-acp/tsconfig.build.json @@ -0,0 +1,21 @@ +// Emit configuration for `lib/`. The base config type-checks src, tests, and +// scripts with `noEmit`; this one compiles src alone, because tests and build +// scripts are not part of what ships. +// +// `rewriteRelativeImportExtensions` (inherited) is what makes the `./bridge.ts` +// import style in src/ legal here: TypeScript rewrites those specifiers to +// `./bridge.js` on the way out, so the emitted tree is plain Node ESM with no +// loader hook. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "lib", + "declaration": true, + "declarationDir": "lib/types", + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/packages/dsh-acp/tsconfig.json b/packages/dsh-acp/tsconfig.json new file mode 100644 index 0000000000..37d4009701 --- /dev/null +++ b/packages/dsh-acp/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM"], + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src", "tests", "scripts"] +} diff --git a/patches/mdast-util-gfm-autolink-literal@2.0.1.patch b/patches/mdast-util-gfm-autolink-literal@2.0.1.patch new file mode 100644 index 0000000000..df6196ae29 --- /dev/null +++ b/patches/mdast-util-gfm-autolink-literal@2.0.1.patch @@ -0,0 +1,49 @@ +diff --git a/lib/index.js b/lib/index.js +index c5ca771c24dd914e342f791716a822431ee32b3a..1b159d4cff7588581de342796858811c340a48f4 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -132,7 +132,7 @@ function transformGfmAutolinkLiterals(tree) { + tree, + [ + [/(https?:\/\/|www(?=\.))([-.\w]+)([^ \t\r\n]*)/gi, findUrl], +- [/(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail] ++ [/(^|[\s\p{P}\p{S}])([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail] + ], + {ignore: ['link', 'linkReference']} + ) +@@ -189,27 +189,29 @@ function findUrl(_, protocol, domain, path, match) { + /** + * @type {ReplaceFunction} + * @param {string} _ ++ * @param {string} prefix + * @param {string} atext + * @param {string} label +- * @param {RegExpMatchObject} match +- * @returns {Link | false} ++ * @returns {Array | Link | false} + */ +-function findEmail(_, atext, label, match) { ++function findEmail(_, prefix, atext, label) { + if ( +- // Not an expected previous character. +- !previous(match, true) || ++ // An email should not follow a slash. ++ prefix === '/' || + // Label ends in not allowed character. + /[-\d_]$/.test(label) + ) { + return false + } + +- return { ++ const result = { + type: 'link', + title: null, + url: 'mailto:' + atext + '@' + label, + children: [{type: 'text', value: atext + '@' + label}] + } ++ ++ return prefix ? [{type: 'text', value: prefix}, result] : result + } + + /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68c561f17b..c484022174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + mdast-util-gfm-autolink-literal@2.0.1: + hash: 23ece47d024ddb26beb830db75c7075ea8820bc730565a8e59895957253e5707 + path: patches/mdast-util-gfm-autolink-literal@2.0.1.patch + importers: .: @@ -92,6 +97,15 @@ importers: specifier: ^7.0.4 version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + sdk/typescript: + devDependencies: + '@types/node': + specifier: 22.19.7 + version: 22.19.7 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + src/miniapp-market-web: dependencies: '@phosphor-icons/react': @@ -335,8 +349,8 @@ importers: specifier: ^15.6.6 version: 15.6.6(react@18.3.1) react-virtuoso: - specifier: ^4.14.1 - version: 4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^4.18.11 + version: 4.18.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -4672,8 +4686,8 @@ packages: peerDependencies: react: '>= 0.14.0' - react-virtuoso@4.18.1: - resolution: {integrity: sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==} + react-virtuoso@4.18.11: + resolution: {integrity: sha512-Qeeq9vqa5seCxACvzbQTUeq3s2/Bu+VlwOMF0jfy3E/ZKHLiXWwJpXBhv2rckqYkvm1XRVFLCBMCmqCU8JNEJg==} peerDependencies: react: '>=16 || >=17 || >= 18 || >= 19' react-dom: '>=16 || >=17 || >= 18 || >=19' @@ -9663,7 +9677,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-autolink-literal@2.0.1: + mdast-util-gfm-autolink-literal@2.0.1(patch_hash=23ece47d024ddb26beb830db75c7075ea8820bc730565a8e59895957253e5707): dependencies: '@types/mdast': 4.0.4 ccount: 2.0.1 @@ -9711,7 +9725,7 @@ snapshots: mdast-util-gfm@3.1.0: dependencies: mdast-util-from-markdown: 2.0.2 - mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-autolink-literal: 2.0.1(patch_hash=23ece47d024ddb26beb830db75c7075ea8820bc730565a8e59895957253e5707) mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 @@ -10650,7 +10664,7 @@ snapshots: react: 18.3.1 refractor: 3.6.0 - react-virtuoso@4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-virtuoso@4.18.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d49e3153d8..4629dab91f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,11 @@ packages: + - "sdk/typescript" - "src/web-ui" - "src/mobile-web" - "src/miniapp-market-web" - "src/skin-market-web" - "BitFun-Installer" - "tests/e2e" + +patchedDependencies: + mdast-util-gfm-autolink-literal@2.0.1: patches/mdast-util-gfm-autolink-literal@2.0.1.patch diff --git a/scripts/audit-web-motion.mjs b/scripts/audit-web-motion.mjs new file mode 100644 index 0000000000..1638e16b77 --- /dev/null +++ b/scripts/audit-web-motion.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const repositoryRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoot = path.join(repositoryRoot, 'src/web-ui/src'); +const sourceExtensions = new Set(['.css', '.scss', '.ts', '.tsx']); +const styleExtensions = new Set(['.css', '.scss']); +const excludedRoots = new Set([ + path.join(sourceRoot, 'generated'), + path.join(sourceRoot, 'component-library/preview'), +]); + +function isAuditedSourceFile(file) { + const name = path.basename(file); + return sourceExtensions.has(path.extname(name)) + && !/\.(?:test|spec)\.(?:ts|tsx)$/.test(name); +} + +async function collectFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async entry => { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return excludedRoots.has(absolutePath) ? [] : collectFiles(absolutePath); + } + return isAuditedSourceFile(absolutePath) ? [absolutePath] : []; + })); + return nested.flat(); +} + +function lineNumberAt(source, index) { + return source.slice(0, index).split('\n').length; +} + +function relativePath(file) { + return path.relative(repositoryRoot, file); +} + +function findMatches(source, expression) { + return [...source.matchAll(expression)].map(match => ({ + index: match.index ?? 0, + value: match[0], + groups: match.slice(1), + })); +} + +function withoutComments(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')) + .replace(/(^|\s)\/\/[^\n]*/g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function printLocations(title, locations, limit = 80) { + console.log(`\n${title}: ${locations.length}`); + locations.slice(0, limit).forEach(location => { + console.log(` ${location.file}:${location.line}${location.detail ? ` (${location.detail})` : ''}`); + }); + if (locations.length > limit) { + console.log(` ... ${locations.length - limit} more`); + } +} + +const files = await collectFiles(sourceRoot); +const transitionAll = []; +const scaleZero = []; +const smoothScroll = []; +const unguardedInfinite = []; +const keyframes = new Map(); +let interactiveHandlers = 0; +let motionOptIns = 0; + +for (const file of files) { + const source = await readFile(file, 'utf8'); + const searchableSource = withoutComments(source); + const fileName = relativePath(file); + const extension = path.extname(file); + + if (extension === '.tsx') { + interactiveHandlers += findMatches(searchableSource, /\bon(?:Click|DoubleClick|ContextMenu|PointerDown|MouseDown|KeyDown)\s*=/g).length; + motionOptIns += findMatches(searchableSource, /\bdata-motion\s*=/g).length; + } + + for (const match of findMatches(searchableSource, /\btransition\s*:\s*all\b/g)) { + transitionAll.push({ file: fileName, line: lineNumberAt(source, match.index) }); + } + for (const match of findMatches(searchableSource, /\bscale\(\s*0(?:\.0+)?\s*\)/g)) { + scaleZero.push({ file: fileName, line: lineNumberAt(source, match.index) }); + } + for (const match of findMatches(searchableSource, /\bbehavior\s*:\s*['"]smooth['"]/g)) { + smoothScroll.push({ file: fileName, line: lineNumberAt(source, match.index) }); + } + + if (!styleExtensions.has(extension)) continue; + + for (const match of findMatches(searchableSource, /@(?:-webkit-)?keyframes\s+([\w-]+)/g)) { + const name = match.groups[0]; + const definitions = keyframes.get(name) ?? []; + definitions.push({ file: fileName, line: lineNumberAt(source, match.index) }); + keyframes.set(name, definitions); + } + + const hasInfiniteAnimation = /\banimation(?:-[\w-]+)?\s*:[^;{}]*\binfinite\b/.test(searchableSource); + const hasReducedMotionRule = /@media\s*\([^)]*prefers-reduced-motion\s*:\s*reduce[^)]*\)/.test(searchableSource); + if (hasInfiniteAnimation && !hasReducedMotionRule) { + unguardedInfinite.push({ file: fileName, line: 1 }); + } +} + +const duplicateKeyframes = [...keyframes.entries()] + .filter(([, definitions]) => definitions.length > 1) + .sort((left, right) => right[1].length - left[1].length); + +console.log('BitFun Web UI motion inventory'); +console.log(`Scanned ${files.length} source files under src/web-ui/src.`); +console.log(`Interactive handler attributes: ${interactiveHandlers}`); +console.log(`Explicit data-motion opt-ins: ${motionOptIns}`); +printLocations('transition: all', transitionAll); +printLocations('scale(0)', scaleZero); +printLocations('JavaScript smooth scrolling (verify keyboard and reduced-motion paths)', smoothScroll); +printLocations('Files with infinite animation and no local reduced-motion rule', unguardedInfinite); + +console.log(`\nDuplicate global keyframe names: ${duplicateKeyframes.length}`); +duplicateKeyframes.slice(0, 40).forEach(([name, definitions]) => { + console.log(` ${name}: ${definitions.length}`); + definitions.forEach(definition => console.log(` ${definition.file}:${definition.line}`)); +}); +if (duplicateKeyframes.length > 40) { + console.log(` ... ${duplicateKeyframes.length - 40} more`); +} + +console.log('\nThis command is an inventory, not a pass/fail gate. Review intent before changing layout transitions or virtualized content.'); diff --git a/scripts/check-build-prereqs.test.mjs b/scripts/check-build-prereqs.test.mjs index ef12f4a175..72cfded6d2 100644 --- a/scripts/check-build-prereqs.test.mjs +++ b/scripts/check-build-prereqs.test.mjs @@ -12,7 +12,11 @@ const repoRoot = path.resolve( ); const scriptPath = path.join(repoRoot, 'scripts/check-build-prereqs.mjs'); -function createTestRoot({ nodeModules = false, mobileWebDist = false, sherpaOnnx = null } = {}) { +function createTestRoot({ + nodeModules = false, + mobileWebDist = false, + sherpaOnnx = null, +} = {}) { const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-prereqs-')); if (nodeModules) { @@ -129,6 +133,21 @@ test('fails when mobile-web dist is missing', (t) => { assert.match(result.stderr, /Fix: pnpm run prepare:mobile-web/); }); +test('does not require the DeepSeek profile for cargo check', (t) => { + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.equal(result.status, 0); + assert.doesNotMatch(result.stderr, /dsh bridge profile/); + assert.doesNotMatch(result.stderr, /prepare:dsh-profile/); +}); + test('warns when sherpa-onnx prebuilt dir does not exist (first build)', (t) => { const root = createTestRoot({ nodeModules: true, mobileWebDist: true }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -165,6 +184,7 @@ test('--fix runs fix commands, re-verifies, and exits 0 when errors resolved', ( assert.match(result.stdout, /Attempting fixes/); assert.match(result.stdout, /\$ pnpm install/); assert.match(result.stdout, /\$ pnpm run prepare:mobile-web/); + assert.doesNotMatch(result.stdout, /prepare:dsh-profile/); assert.match(result.stdout, /Re-checking prerequisites/); assert.match(result.stdout, /All errors resolved/); }); diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 8eb356677b..9f5b3bd30e 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -20,16 +20,30 @@ import { } from './core-boundaries/cargo-dependency-boundaries.mjs'; import { checkCliIntegrationTestTopology, + checkExternalSourceIntegrationTestTopologies, checkServicesCoreIntegrationTestTopology, checkServicesIntegrationsIntegrationTestTopology, + claudeCodeAdapterIntegrationTestTargets, cliIntegrationTestTargets, + codexAdapterIntegrationTestTargets, + externalSourcesIntegrationTestTargets, + opencodeAdapterIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './core-boundaries/explicit-test-topology.mjs'; import { crateLayoutRules } from './core-boundaries/rules/crate-layout.mjs'; import { + capabilityContractDependencyRules, coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, + guardedEmptyInternalDefaultManifestPaths, + optionalDependencyFeatureOwnerRules, } from './core-boundaries/rules/feature-rules.mjs'; +import { + agentRuntimeRootPublicModules, + forbiddenContentRules, + publicApiAllowlistRules, + requiredContentRules, +} from './core-boundaries/rules/source-rules.mjs'; const ENTRYPOINT = new URL('./check-core-boundaries.mjs', import.meta.url); const MODULES = [ @@ -50,6 +64,345 @@ const MODULES = [ const TEST_ROOT = join('C:', 'repo'); +test('App Server TypeScript capability is owned by the protocol crate', () => { + const appServerTs = coreClosedFeatureProfileRules.find( + (rule) => rule.manifestPath === 'src/crates/interfaces/app-server/Cargo.toml' + && rule.featureName === 'ts', + ); + assert.deepEqual(appServerTs?.requiredFeatureRefs, [ + 'bitfun-app-server-protocol/ts', + ]); + assert.equal(appServerTs?.exact, true); + + const protocolTs = coreClosedFeatureProfileRules.find( + (rule) => rule.manifestPath === 'src/crates/interfaces/app-server-protocol/Cargo.toml' + && rule.featureName === 'ts', + ); + assert.deepEqual(protocolTs?.requiredFeatureRefs, [ + 'bitfun-core-types/ts', + 'bitfun-product-domains/ts', + 'bitfun-runtime-ports/ts', + 'dep:ts-rs', + ]); + assert.equal(protocolTs?.exact, true); +}); + +test('Agent Runtime leaf capabilities have one managed feature and source contract', async () => { + const rule = capabilityContractDependencyRules.find( + (candidate) => candidate.packageName === 'bitfun-agent-runtime', + ); + assert.ok(rule, 'bitfun-agent-runtime must be a managed capability target'); + assert.deepEqual(Object.keys(rule.featureProfiles).sort(), [ + 'agent-runtime', + 'deep-research', + 'default', + 'native-hook-runtime', + 'native-hook-settings', + ]); + assert.equal(rule.consumers.size, 10); + assert.ok( + guardedEmptyInternalDefaultManifestPaths.includes( + 'src/crates/execution/agent-runtime/Cargo.toml', + ), + ); + assert.ok(requiredContentRules.some( + (sourceRule) => sourceRule.path === 'src/crates/execution/agent-runtime/src/lib.rs' + && sourceRule.reason.includes('leaf capability modules'), + )); + const publicApiRule = publicApiAllowlistRules.find( + (sourceRule) => sourceRule.path === 'src/crates/execution/agent-runtime/src/lib.rs', + ); + assert.ok(publicApiRule, 'bitfun-agent-runtime root must have a closed public module allowlist'); + assert.deepEqual( + new Set(publicApiRule.allowedSymbols), + new Set(agentRuntimeRootPublicModules), + ); + const flatRootRule = forbiddenContentRules.find( + (sourceRule) => sourceRule.path === 'src/crates/execution/agent-runtime/src/lib.rs' + && sourceRule.reason.includes('flat feature-owned module wrapper'), + ); + assert.ok(flatRootRule, 'bitfun-agent-runtime root must reject non-wrapper source lines'); + const rootSource = await readFile( + new URL('../src/crates/execution/agent-runtime/src/lib.rs', import.meta.url), + 'utf8', + ); + assert.equal(flatRootRule.patterns[0].regex.test(rootSource), false); + for (const mutation of [ + '#[doc(hidden)] pub mod accidental_feature_free_api;', + 'pub union AccidentalFeatureFreeApi { value: u64 }', + 'const DOC: &str = "{";\npub mod accidental_feature_free_api;', + ]) { + assert.equal( + flatRootRule.patterns[0].regex.test(`${rootSource}\n${mutation}`), + true, + `Agent Runtime root must reject mutation: ${mutation}`, + ); + } +}); + +test('Core and ACP defaults preserve their explicit assembly contracts', async () => { + const [coreManifest, acpManifest] = await Promise.all([ + readFile(new URL('../src/crates/assembly/core/Cargo.toml', import.meta.url), 'utf8'), + readFile(new URL('../src/crates/interfaces/acp/Cargo.toml', import.meta.url), 'utf8'), + ]); + + assert.deepEqual(parseManifestFeatures(coreManifest).default, []); + assert.deepEqual( + new Set(parseManifestFeatures(acpManifest).default), + new Set(['client', 'server']), + ); +}); + +test('consumers do not repeat guarded empty internal defaults', async () => { + const cargoBoundaries = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + assert.equal( + typeof cargoBoundaries.findRedundantInternalDefaultFeatureDisables, + 'function', + ); + + const emptyOwner = { + ...packageAt('empty-owner', 'src/crates/contracts/empty-owner/Cargo.toml'), + features: { default: [] }, + }; + const compatibilityOwner = { + ...packageAt('compatibility-owner', 'src/crates/interfaces/compatibility-owner/Cargo.toml'), + features: { default: ['client', 'server'] }, + }; + const unguardedEmptyOwner = { + ...packageAt('unguarded-empty-owner', 'src/crates/contracts/unguarded-empty-owner/Cargo.toml'), + features: { default: [] }, + }; + const consumer = packageAt('consumer', 'src/apps/consumer/Cargo.toml', [ + pathDependency('src/crates/contracts/empty-owner', { + name: 'empty-owner', + usesDefaultFeatures: false, + }), + pathDependency('src/crates/interfaces/compatibility-owner', { + name: 'compatibility-owner', + usesDefaultFeatures: false, + }), + pathDependency('src/crates/contracts/unguarded-empty-owner', { + name: 'unguarded-empty-owner', + usesDefaultFeatures: false, + }), + ]); + + const violations = cargoBoundaries.findRedundantInternalDefaultFeatureDisables( + [emptyOwner, compatibilityOwner, unguardedEmptyOwner, consumer], + { + root: TEST_ROOT, + guardedManifests: ['src/crates/contracts/empty-owner/Cargo.toml'], + }, + ); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /empty-owner.*redundant/); +}); + +test('guarded internal defaults stay explicitly empty', async () => { + const cargoBoundaries = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const manifestPath = 'src/crates/contracts/empty-owner/Cargo.toml'; + const owner = { + ...packageAt('empty-owner', manifestPath), + features: { default: [] }, + }; + assert.deepEqual( + cargoBoundaries.findGuardedInternalDefaultFeatureViolations( + [owner], + { root: TEST_ROOT, guardedManifests: [manifestPath] }, + ), + [], + ); + + owner.features.default = ['expanded']; + const violations = cargoBoundaries.findGuardedInternalDefaultFeatureViolations( + [owner], + { root: TEST_ROOT, guardedManifests: [manifestPath] }, + ); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /guarded default feature must stay explicitly empty/); +}); + +test('portable contract crates expose only capability-local feature slices', async () => { + const [runtimePortsManifest, agentToolsManifest] = await Promise.all([ + readFile(new URL('../src/crates/contracts/runtime-ports/Cargo.toml', import.meta.url), 'utf8'), + readFile(new URL('../src/crates/execution/tool-contracts/Cargo.toml', import.meta.url), 'utf8'), + ]); + + const runtimePortFeatures = parseManifestFeatures(runtimePortsManifest); + assert.deepEqual(runtimePortFeatures.default, []); + assert.deepEqual( + new Set(Object.keys(runtimePortFeatures)), + new Set([ + 'default', + 'agent-api', + 'git-port', + 'permission', + 'plugin-runtime', + 'remote-exec-port', + 'remote-workspace-ports', + 'runtime-event-port', + 'script-tool-runtime', + 'terminal-port', + 'tool-runtime-handles', + 'ts', + 'workspace-ports', + ]), + ); + assert.deepEqual(runtimePortFeatures['agent-api'], ['dep:bitfun-core-types']); + assert.deepEqual(runtimePortFeatures['plugin-runtime'], []); + assert.deepEqual(runtimePortFeatures['script-tool-runtime'], []); + assert.deepEqual(new Set(runtimePortFeatures['workspace-ports']), new Set(['dep:anyhow', 'dep:tokio-util'])); + assert.deepEqual(runtimePortFeatures['terminal-port'], ['dep:tokio']); + assert.deepEqual(runtimePortFeatures['remote-exec-port'], ['dep:tokio']); + assert.deepEqual( + new Set(runtimePortFeatures['tool-runtime-handles']), + new Set([ + 'workspace-ports', + 'terminal-port', + 'remote-exec-port', + ]), + ); + + const agentToolFeatures = parseManifestFeatures(agentToolsManifest); + assert.deepEqual(agentToolFeatures.default, []); + assert.deepEqual(agentToolFeatures['acp-bridge'], []); + assert.deepEqual(agentToolFeatures['computer-use-contract'], []); + assert.deepEqual(agentToolFeatures['element-token'], []); + assert.deepEqual(agentToolFeatures['mcp-bridge'], []); +}); + +test('runtime-port capability source gates protect modules and public exports', async () => { + const { requiredContentRules } = await import( + './core-boundaries/rules/source/required-rules.mjs' + ); + const sourceRule = requiredContentRules.find( + (rule) => rule.path === 'src/crates/contracts/runtime-ports/src/lib.rs' + && rule.reason.includes('capability features'), + ); + const patterns = sourceRule?.patterns.map(({ regex }) => regex.source).join('\n') ?? ''; + + for (const [feature, moduleName] of [ + ['workspace-ports', 'workspace_ports'], + ['terminal-port', 'terminal_port'], + ['remote-exec-port', 'remote_exec_port'], + ['remote-workspace-ports', 'remote_workspace_ports'], + ['runtime-event-port', 'runtime_event_port'], + ['git-port', 'git_port'], + ['tool-runtime-handles', 'tool_runtime_handles'], + ]) { + assert.match(patterns, new RegExp(`${feature}.*mod ${moduleName}`)); + assert.match(patterns, new RegExp(`${feature}.*pub use ${moduleName}`)); + } +}); + +test('runtime-ports async dependencies stay behind their exact port owners', () => { + const ownerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'runtime-ports', + ); + const ownersByDependency = new Map( + ownerRule.dependencies.map((dependency) => [ + dependency.depName, + new Set(dependency.ownerFeatures), + ]), + ); + + assert.deepEqual( + ownersByDependency.get('anyhow'), + new Set(['workspace-ports']), + ); + assert.deepEqual( + ownersByDependency.get('tokio-util'), + new Set(['workspace-ports']), + ); + assert.deepEqual( + ownersByDependency.get('tokio'), + new Set(['remote-exec-port', 'terminal-port']), + ); +}); + +test('Core feature-free dependencies stay attached to their exact runtime owners', () => { + const coreOwnerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'core', + ); + const ownersByDependency = new Map( + coreOwnerRule.dependencies.map((dependency) => [ + dependency.depName, + new Set(dependency.ownerFeatures), + ]), + ); + + assert.deepEqual(ownersByDependency.get('base64'), new Set(['agent-runtime', 'dispatch-store'])); + assert.deepEqual(ownersByDependency.get('futures'), new Set(['agent-runtime'])); + assert.deepEqual(ownersByDependency.get('regex'), new Set(['agent-runtime'])); + assert.deepEqual( + ownersByDependency.get('bitfun-agent-tools'), + new Set(['agent-runtime', 'local-storage', 'mcp-runtime']), + ); + assert.deepEqual(ownersByDependency.get('fluent-bundle'), new Set(['i18n-runtime'])); + assert.deepEqual(ownersByDependency.get('unic-langid'), new Set(['i18n-runtime'])); + assert.deepEqual( + ownersByDependency.get('tokio-util'), + new Set(['agent-runtime', 'debug-log']), + ); +}); + +test('Services Core feature-free dependencies stay behind exact text and async IO owners', () => { + const ownerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'services-core', + ); + const ownersByDependency = new Map( + ownerRule.dependencies.map((dependency) => [ + dependency.depName, + new Set(dependency.ownerFeatures), + ]), + ); + + assert.deepEqual( + ownersByDependency.get('regex'), + new Set(['diagnostics', 'filesystem', 'local-storage', 'markdown', 'workspace-instructions']), + ); + assert.deepEqual(ownersByDependency.get('similar'), new Set(['diff', 'local-storage'])); + assert.deepEqual( + ownersByDependency.get('tokio'), + new Set([ + 'diff', + 'filesystem', + 'json-io', + 'local-storage', + 'lsp', + 'permission', + 'process-runtime', + 'workspace-instructions', + 'workspace-runtime', + 'workspace-text-runtime', + ]), + ); +}); + +test('Services Core text runtime features keep independent exact owner profiles', () => { + const profiles = new Map( + coreClosedFeatureProfileRules + .filter((rule) => rule.manifestPath === 'src/crates/services/services-core/Cargo.toml') + .map((rule) => [rule.featureName, rule.requiredFeatureRefs]), + ); + + assert.deepEqual(profiles.get('diagnostics'), ['dep:regex']); + assert.deepEqual(profiles.get('diff'), [ + 'dep:similar', + 'dep:tokio', + 'tokio/rt', + 'tokio/time', + ]); + assert.deepEqual(profiles.get('workspace-text-runtime'), [ + 'dep:tokio', + 'tokio/rt', + ]); +}); + function parseManifestFeatures(manifest) { const section = manifest.match(/^\[features\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m)?.[1] ?? ''; const features = {}; @@ -97,6 +450,61 @@ function pathDependency(repoCratePath, options = {}) { }; } +const RUNTIME_PORT_FEATURE_PROFILES = { + default: [], + 'agent-api': ['dep:bitfun-core-types'], + 'git-port': [], + permission: ['dep:bitfun-product-domains'], + 'plugin-runtime': [], + 'remote-exec-port': ['dep:tokio'], + 'remote-workspace-ports': [], + 'runtime-event-port': [], + 'script-tool-runtime': [], + 'terminal-port': ['dep:tokio'], + 'tool-runtime-handles': ['workspace-ports', 'terminal-port', 'remote-exec-port'], + ts: [ + 'dep:ts-rs', + 'agent-api', + 'permission', + 'bitfun-core-types/ts', + 'bitfun-product-domains?/ts', + ], + 'workspace-ports': ['dep:anyhow', 'dep:tokio-util'], +}; + +const AGENT_TOOL_FEATURE_PROFILES = { + default: [], + 'acp-bridge': [], + 'computer-use-contract': [], + 'element-token': [], + 'mcp-bridge': [], +}; + +function capabilityPackage(name, repoManifestPath, featureProfiles) { + return { + ...packageAt(name, repoManifestPath), + features: structuredClone(featureProfiles), + }; +} + +function agentToolsCapabilityPackage() { + return { + ...capabilityPackage( + 'bitfun-agent-tools', + 'src/crates/execution/tool-contracts/Cargo.toml', + AGENT_TOOL_FEATURE_PROFILES, + ), + dependencies: [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + usesDefaultFeatures: false, + })], + }; +} + +function findTestCapabilityViolations(finder, packages, rules) { + return finder(packages, rules, { root: TEST_ROOT }); +} + function integrationTarget(name, sourcePath, requiredFeatures = []) { return { kind: ['test'], @@ -255,6 +663,216 @@ test('service integration tests keep their reviewed explicit target topology', ( assert.deepEqual(checkServicesIntegrationsIntegrationTestTopology(repositoryRoot), []); }); +test('contract and AI adapter tests keep reviewed feature and failure-domain topology', async () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + const topology = await import('./core-boundaries/explicit-test-topology.mjs'); + + assert.deepEqual(topology.coreTypesIntegrationTestTargets, [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.runtimePortsIntegrationTestTargets, [ + { + name: 'plugin_runtime_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + ], + requiredFeatures: ['plugin-runtime'], + }, + { + name: 'git_port_contracts', + path: 'tests/git_port_contracts.rs', + requiredFeatures: ['git-port'], + }, + { + name: 'script_tool_port_contracts', + path: 'tests/script_tool_port_contracts.rs', + requiredFeatures: ['script-tool-runtime'], + }, + { + name: 'session_store_contracts', + path: 'tests/session_store_contracts.rs', + requiredFeatures: ['workspace-ports'], + }, + ]); + assert.deepEqual(topology.productDomainsIntegrationTestTargets, [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, + ]); + assert.deepEqual(topology.aiAdaptersIntegrationTestTargets, [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.productCapabilitiesIntegrationTestTargets, [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.checkBuildGraphContractIntegrationTestTopologies(repositoryRoot), []); + + const widenedOwnerErrors = validateExplicitIntegrationTestTopology({ + manifestText: [ + '[package]', + 'autotests = false', + '[[test]]', + 'name = "external_source_contracts"', + 'path = "tests/external_source_contracts.rs"', + 'required-features = ["product-full"]', + ].join('\n'), + expectedTargets: [{ + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + requiredFeatures: ['external-sources'], + }], + topLevelRustFiles: ['tests/external_source_contracts.rs'], + rootSources: new Map([[ + 'tests/external_source_contracts.rs', + '#![cfg(feature = "product-full")]\n', + ]]), + leafRustFiles: [], + leafSources: new Map(), + }); + assert.match(widenedOwnerErrors.join('\n'), /required-features.*external-sources/); +}); + +test('external source integration tests keep reviewed owner and process boundaries', () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + + assert.deepEqual(opencodeAdapterIntegrationTestTargets, [ + { name: 'opencode_mcp_adapter', path: 'tests/opencode_mcp_adapter.rs' }, + { name: 'opencode_source_adapter', path: 'tests/opencode_source_adapter.rs' }, + { + name: 'opencode_static_source_contracts', + path: 'tests/opencode_static_source_contracts.rs', + leaves: [ + 'tests/opencode_static_source_contracts/hook_source.rs', + 'tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_workspace_references.rs', + ], + forbidRequiredFeatures: true, + }, + { name: 'tool_source_contracts', path: 'tests/tool_source_contracts.rs' }, + ]); + assert.deepEqual(claudeCodeAdapterIntegrationTestTargets, [ + { + name: 'claude_code_source_contracts', + path: 'tests/claude_code_source_contracts.rs', + leaves: [ + 'tests/claude_code_source_contracts/command_source.rs', + 'tests/claude_code_source_contracts/hook_source.rs', + 'tests/claude_code_source_contracts/mcp_source.rs', + 'tests/claude_code_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(codexAdapterIntegrationTestTargets, [ + { + name: 'codex_source_contracts', + path: 'tests/codex_source_contracts.rs', + leaves: [ + 'tests/codex_source_contracts/hook_source.rs', + 'tests/codex_source_contracts/mcp_source.rs', + 'tests/codex_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(externalSourcesIntegrationTestTargets, [ + { + name: 'external_source_coordination_contracts', + path: 'tests/external_source_coordination_contracts.rs', + leaves: [ + 'tests/external_source_coordination_contracts/control_plane.rs', + 'tests/external_source_coordination_contracts/coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/hook_coordinator.rs', + 'tests/external_source_coordination_contracts/mcp_coordinator.rs', + 'tests/external_source_coordination_contracts/subagent_coordinator.rs', + 'tests/external_source_coordination_contracts/tool_coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/workspace_reference.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual( + checkExternalSourceIntegrationTestTopologies(repositoryRoot), + [], + ); +}); + test('runtime-services test support is absent from ordinary library builds', async () => { const [manifest, library] = await Promise.all([ readFile( @@ -368,7 +986,7 @@ test('multiple crate feature gates combine as required feature AND conditions', assert.match(violations[0].message, /second/); }); -test('product entrypoints must disable bitfun-core default features', () => { +test('product entrypoints may inherit the guarded empty bitfun-core default', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const app = packageAt('entry', 'src/apps/example/Cargo.toml', [ pathDependency('src/crates/assembly/core', { @@ -382,8 +1000,7 @@ test('product entrypoints must disable bitfun-core default features', () => { { root: TEST_ROOT, crateLayoutRules }, ); - assert.equal(violations.length, 1); - assert.match(violations[0].message, /default-features = false/); + assert.deepEqual(violations, []); }); test('Core Agent Runtime baseline excludes concrete capability unions', () => { @@ -414,8 +1031,30 @@ test('Core Agent Runtime baseline excludes concrete capability unions', () => { } }); +test('Core optional document and subscription capabilities have independent modifiers', () => { + const ruleByFeature = new Map( + coreClosedFeatureProfileRules.map((rule) => [rule.featureName, rule]), + ); + assert.deepEqual(ruleByFeature.get('document-read')?.requiredFeatureRefs, [ + 'tool-runtime?/document-read', + ]); + assert.deepEqual(ruleByFeature.get('subscription-auth')?.requiredFeatureRefs, [ + 'bitfun-ai-adapters?/subscription-auth', + ]); + assert.deepEqual(ruleByFeature.get('ai-adapter-runtime')?.requiredFeatureRefs, [ + 'dep:bitfun-ai-adapters', + ]); + assert.ok( + !ruleByFeature.get('tools-basic')?.requiredFeatureRefs.includes('tool-runtime/document-read'), + 'baseline tools must not activate document conversion', + ); +}); + test('Core product-full explicitly assembles service and tool capability owners', () => { for (const required of [ + 'document-read', + 'subscription-auth', + 'i18n-runtime', 'model-catalog', 'mcp-runtime', 'remote-connect', @@ -485,12 +1124,320 @@ test('explicit product entrypoint bitfun-core feature selections pass', () => { ); }); -const ACP_REVIEWED_CORE_FEATURES = [ +test('Desktop and Server must retain the full product Core capability closure', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + + for (const [name, manifestPath] of [ + ['bitfun-desktop', 'src/apps/desktop/Cargo.toml'], + ['bitfun-server', 'src/apps/server/Cargo.toml'], + ]) { + const product = packageAt(name, manifestPath, [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['i18n-runtime'], + }), + ]); + const messages = findProductEntrypointCoreFeatureViolations( + [product, core], + { root: TEST_ROOT, crateLayoutRules }, + ).map((violation) => violation.message); + + assert.deepEqual(messages, [ + `${name} Core capability closure must select exactly product-full`, + ]); + } +}); + +test('Desktop and Server must retain their Core product dependency', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + for (const [name, manifestPath] of [ + ['bitfun-desktop', 'src/apps/desktop/Cargo.toml'], + ['bitfun-server', 'src/apps/server/Cargo.toml'], + ]) { + const product = packageAt(name, manifestPath); + assert.deepEqual( + findProductEntrypointCoreFeatureViolations( + [product, core], + { root: TEST_ROOT, crateLayoutRules }, + ).map((violation) => violation.message), + [`${name} Core capability closure must keep the bitfun-core dependency`], + ); + } +}); + +test('Desktop must select only the ACP client role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client', 'server'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); +}); + +test('ACP consumers must disable compatibility default roles', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: true, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must set default-features = false on every dependency/); +}); + +test('CLI must select both ACP roles explicitly', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const cli = packageAt('bitfun-cli', 'src/apps/cli/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [cli, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /CLI ACP role selection must include server/); +}); + +test('new product entrypoints must register an explicit ACP role selection', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const newHost = packageAt('bitfun-new-host', 'src/apps/new-host/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [newHost, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must register an explicit role selection/); +}); + +test('ACP roles must be selected by an unconditional normal dependency', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + kind: 'dev', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match( + violations[0].message, + /Desktop ACP role selection must keep an unconditional normal bitfun-acp dependency/, + ); +}); + +test('reviewed ACP roles require an unconditional normal dependency', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + target: 'cfg(windows)', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must keep an unconditional normal bitfun-acp dependency/); +}); + +test('target-specific ACP edges cannot expand a reviewed product role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + target: 'cfg(windows)', + usesDefaultFeatures: false, + features: ['server'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); +}); + +test('dev and build ACP edges cannot expand a reviewed product role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + + for (const kind of ['dev', 'build']) { + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + kind, + usesDefaultFeatures: false, + features: ['server'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1, `${kind} dependency must not widen Desktop ACP roles`); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); + } +}); + +test('reviewed ACP product dependencies must not become optional', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + optional: true, + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 2); + assert.match(violations[0].message, /must keep an unconditional normal bitfun-acp dependency/); + assert.match(violations[1].message, /must not make a bitfun-acp dependency optional/); +}); + +test('target, dev, and build ACP consumers must still register their role selection', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + for (const dependency of [ + { target: 'cfg(windows)' }, + { kind: 'dev' }, + { kind: 'build' }, + ]) { + const newHost = packageAt('bitfun-new-host', 'src/apps/new-host/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + ...dependency, + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [newHost, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must register an explicit role selection/); + } +}); + +const SDK_HOST_REVIEWED_CORE_FEATURES = [ 'agent-runtime', + 'document-read', + 'subscription-auth', 'deep-research', 'lsp', 'external-sources', - 'ssh-remote', 'tools-basic', 'tools-git', 'tools-mcp', @@ -502,6 +1449,11 @@ const ACP_REVIEWED_CORE_FEATURES = [ 'tools-agent-control', ]; +const ACP_REVIEWED_CORE_FEATURES = [ + ...SDK_HOST_REVIEWED_CORE_FEATURES, + 'ssh-remote', +]; + const CLI_REVIEWED_CORE_FEATURES = [ ...ACP_REVIEWED_CORE_FEATURES, 'remote-connect', @@ -511,9 +1463,130 @@ const CLI_REVIEWED_CORE_FEATURES = [ const APP_SERVER_REVIEWED_CORE_FEATURES = [ 'external-sources', 'git', + 'i18n-runtime', 'remote-connect', ]; +test('SDK Host Core capability closure keeps every reviewed owner', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES.filter( + (feature) => feature !== 'external-sources', + ), + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-sdk-host-app Core capability closure must include external-sources', + ]); +}); + +test('SDK Host closure rejects unreviewed capability owners below Core', () => { + const cases = [ + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-connect'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh-concrete'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'function-agents'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'announcement'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'debug-log'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'product-full'], + ['bitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'function-agents'], + ['bitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'product-full'], + ['bitfun-services-core', 'src/crates/services/services-core/Cargo.toml', 'dispatch-workspace'], + ]; + + for (const [ownerName, ownerManifest, forbiddenFeature] of cases) { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const owner = { + ...packageAt(ownerName, ownerManifest), + features: { [forbiddenFeature]: [] }, + }; + const bridge = packageAt('bridge', 'src/crates/assembly/bridge/Cargo.toml', [ + pathDependency(ownerManifest.replace('/Cargo.toml', ''), { + name: ownerName, + usesDefaultFeatures: false, + features: [forbiddenFeature], + }), + ]); + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES, + }), + pathDependency('src/crates/assembly/bridge', { name: 'bridge' }), + ], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, bridge, core, owner], + { root: TEST_ROOT, crateLayoutRules }, + ); + + const forbiddenOwner = `${ownerName}/${forbiddenFeature}`; + assert.equal(violations.length, 1, forbiddenOwner); + assert.match( + violations[0].message, + new RegExp(forbiddenOwner), + ); + } +}); + +test('SDK Host closure inspects lower owners forwarded by reviewed Core features', () => { + const ownerManifest = 'src/crates/services/services-integrations/Cargo.toml'; + const core = { + ...packageAt( + 'bitfun-core', + 'src/crates/assembly/core/Cargo.toml', + [pathDependency('src/crates/services/services-integrations', { + name: 'bitfun-services-integrations', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + 'external-sources': ['bitfun-services-integrations/remote-connect'], + }, + }; + const owner = { + ...packageAt('bitfun-services-integrations', ownerManifest), + features: { 'remote-connect': [] }, + }; + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES, + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, core, owner], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match( + violations[0].message, + /bitfun-services-integrations\/remote-connect/, + ); +}); + test('App Server Core capability closure keeps its production Git owner', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const appServer = packageAt( @@ -536,6 +1609,30 @@ test('App Server Core capability closure keeps its production Git owner', () => ]); }); +test('App Server Core capability closure keeps its backend i18n runtime', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const appServer = packageAt( + 'bitfun-app-server', + 'src/crates/interfaces/app-server/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: APP_SERVER_REVIEWED_CORE_FEATURES.filter( + (feature) => feature !== 'i18n-runtime', + ), + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [appServer, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-app-server Core capability closure must include i18n-runtime', + ]); +}); + test('App Server reviewed Core capability closure remains independently valid', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const appServer = packageAt( @@ -1368,6 +2465,21 @@ test('workspace Tokio capabilities stay crate-owned', async () => { assert.doesNotMatch(workspaceTokio, /(?:^|,\s*)features\s*=/); const packages = collectCargoMetadataPackages({ root: repositoryRoot }); assert.deepEqual(findTokioDependencyFeatureViolations(packages), []); + + const integrations = packages.find((pkg) => pkg.name === 'bitfun-services-integrations'); + const mutatedPackages = packages.map((pkg) => pkg === integrations + ? { + ...pkg, + dependencies: pkg.dependencies.map((dependency) => + dependency.name === 'tokio' && (dependency.kind ?? null) === null + ? { ...dependency, features: ['net'] } + : dependency), + } + : pkg); + assert.ok( + findTokioDependencyFeatureViolations(mutatedPackages).some((violation) => + violation.message === 'bitfun-services-integrations has unexpected base Tokio capabilities: net'), + ); }); test('services integrations Tokio owner contracts reject feature-union masking', async () => { @@ -1399,7 +2511,7 @@ test('services integrations Reqwest policy uses Cargo-decoded feature references reqwest = ["dep:reqwest"] announcement = ["reqwest", "reqwest/rustls"] file-watch = ["reqwest?/__native-tls"] -mcp = ["reqwest"] +mcp = ["reqwest", "reqwest/rustls", "reqwest/json"] models-dev = ["reqwest", "reqwest/rustls", "reqwest/system-proxy"] speech = ["reqwest", "reqwest/rustls", "reqwest/http3"] `); @@ -1407,8 +2519,9 @@ speech = ["reqwest", "reqwest/rustls", "reqwest/http3"] const messages = findServicesIntegrationsReqwestFeatureViolations(pkg) .map((violation) => violation.message) .join('\n'); + assert.match(messages, /announcement.*missing Reqwest feature reference reqwest\/json/); assert.match(messages, /file-watch.*outside its reviewed owner features/); - assert.match(messages, /mcp.*missing reqwest\/rustls/); + assert.match(messages, /mcp.*missing Reqwest feature reference reqwest\/stream/); assert.doesNotMatch(messages, /models-dev.*system-proxy/); assert.match(messages, /speech.*unreviewed Reqwest feature reference reqwest\/http3/); }); @@ -1422,11 +2535,7 @@ test('direct Reqwest clients reject extra decoded dependency and package feature uses_default_features: false, features: [ 'http2', - 'json', 'stream', - 'multipart', - 'query', - 'form', 'rustls', '__native-tls', ], @@ -1449,15 +2558,19 @@ test('direct Reqwest clients reject extra decoded dependency and package feature }); test('AI adapters Reqwest profile owns the supported SOCKS transport', () => { - const baseFeatures = ['http2', 'json', 'stream', 'multipart', 'query', 'form']; - const valid = packageAt('bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [{ - name: 'reqwest', - kind: null, - optional: false, - uses_default_features: false, - features: [...baseFeatures, 'rustls', 'socks'], - }]); - const missingSocks = packageAt( + const baseFeatures = ['http2', 'json', 'stream']; + const valid = { + ...packageAt('bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [{ + name: 'reqwest', + kind: null, + optional: false, + uses_default_features: false, + features: [...baseFeatures, 'rustls', 'socks'], + }]), + features: { 'subscription-auth': ['reqwest/form'] }, + }; + const missingSocks = { + ...packageAt( 'bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [{ @@ -1467,7 +2580,9 @@ test('AI adapters Reqwest profile owns the supported SOCKS transport', () => { uses_default_features: false, features: [...baseFeatures, 'rustls'], }], - ); + ), + features: { 'subscription-auth': ['reqwest/form'] }, + }; assert.deepEqual(findReqwestDependencyFeatureViolations([valid]), []); const messages = findReqwestDependencyFeatureViolations([missingSocks]) @@ -1477,14 +2592,14 @@ test('AI adapters Reqwest profile owns the supported SOCKS transport', () => { }); test('Reqwest metadata policy covers URL-only and future dependency owners', () => { - const baseFeatures = ['http2', 'json', 'stream', 'multipart', 'query', 'form']; + const coreFeatures = []; const core = { ...packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml', [{ name: 'reqwest', kind: null, optional: true, uses_default_features: false, - features: baseFeatures, + features: coreFeatures, }]), features: { product: ['dep:reqwest', 'reqwest/__native-tls'] }, }; @@ -1493,7 +2608,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () kind: null, optional: false, uses_default_features: false, - features: [...baseFeatures, 'rustls'], + features: ['http2', 'rustls', 'stream'], }]); const duplicate = packageAt( 'bitfun-services-integrations', @@ -1504,7 +2619,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () kind: null, optional: true, uses_default_features: false, - features: baseFeatures, + features: ['http2'], }, { name: 'reqwest', @@ -1513,7 +2628,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () optional: true, target: 'cfg(windows)', uses_default_features: false, - features: [...baseFeatures, '__native-tls'], + features: ['http2', '__native-tls'], }, ], ); @@ -1526,6 +2641,22 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () assert.match(messages, /bitfun-services-integrations.*exactly one normal Reqwest dependency/); }); +test('Reqwest consumers inherit the workspace version without duplicating feature rules', async () => { + const { requiredContentRules } = await import( + './core-boundaries/rules/source/required-rules.mjs' + ); + const rules = requiredContentRules.filter((rule) => + rule.reason.includes('Reqwest consumers must inherit the workspace-owned compatible version') + ); + + assert.equal(rules.length, 7); + for (const rule of rules) { + const pattern = rule.patterns[0].regex; + assert.match('reqwest = { workspace = true, features = ["rustls"] }', pattern); + assert.doesNotMatch('reqwest = { version = "99", features = ["rustls"] }', pattern); + } +}); + test('resolved Reqwest feature union rejects every native TLS backend alias', () => { const violations = findResolvedReqwestNativeTlsViolations( [ @@ -1980,7 +3111,10 @@ test('split core boundary check keeps self-test and default execution behavior', }); test('optional dependency ownership rejects undeclared direct feature owners', async () => { - const { unexpectedDependencyOwnerFeatures } = await import( + const { + featureReferencesOptionalDependencyOwner, + unexpectedDependencyOwnerFeatures, + } = await import( './core-boundaries/manifest-feature-helpers.mjs' ); const features = new Map([ @@ -1996,7 +3130,31 @@ test('optional dependency ownership rejects undeclared direct feature owners', a depName: 'example', ownerFeatures: ['declared'], }).map(([featureName]) => featureName), - ['missing', 'feature-ref'], + ['missing', 'feature-ref', 'weak-ref'], + ); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('declared'), 'example'), true); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('weak-ref'), 'example'), true); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('unrelated'), 'example'), false); +}); + +test('optional dependency ownership rejects hidden aliases but permits reviewed aggregates', async () => { + const { unexpectedDependencyOwnerFeatures } = await import( + './core-boundaries/manifest-feature-helpers.mjs' + ); + const features = new Map([ + ['owner', { refs: ['dep:example'], line: 1 }], + ['reviewed-aggregate', { refs: ['owner'], line: 2 }], + ['sneaky', { refs: ['owner'], line: 3 }], + ['bad-aggregate', { refs: ['owner', 'dep:example'], line: 4 }], + ]); + + assert.deepEqual( + unexpectedDependencyOwnerFeatures( + features, + { depName: 'example', ownerFeatures: ['owner'] }, + new Set(['reviewed-aggregate', 'bad-aggregate']), + ).map(([featureName]) => featureName), + ['sneaky', 'bad-aggregate'], ); }); @@ -2021,14 +3179,20 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:base64', 'dep:chrono', 'dep:ignore', + 'dep:regex', 'dep:sha2', + 'dep:tokio', 'tokio/fs', + 'tokio/rt', ]); assert.deepEqual(profiles.get('json-io'), [ 'dep:fs2', + 'dep:tokio', 'dep:windows', 'tokio/fs', + 'tokio/rt', 'tokio/sync', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_Storage_FileSystem', ]); @@ -2038,29 +3202,40 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:chrono', 'dep:fs2', 'dep:libc', + 'dep:regex', 'dep:sha2', + 'dep:similar', + 'dep:tokio', 'dep:windows', 'tokio/fs', + 'tokio/rt', 'tokio/sync', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_Storage_FileSystem', ]); assert.deepEqual(profiles.get('process-runtime'), [ 'dep:libc', + 'dep:tokio', 'dep:which', 'dep:win32job', 'dep:windows', 'tokio/io-util', 'tokio/process', + 'tokio/rt', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', 'windows/Win32_System_Threading', ]); assert.deepEqual(profiles.get('workspace-instructions'), [ 'dep:globset', + 'dep:regex', 'dep:serde_yaml', + 'dep:tokio', 'tokio/fs', 'tokio/io-util', + 'tokio/rt', ]); assert.deepEqual(profiles.get('lsp'), [ 'dep:anyhow', @@ -2076,6 +3251,8 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:anyhow', 'dep:async-trait', 'dep:bitfun-runtime-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/workspace-ports', 'dep:dunce', 'process-runtime', 'tokio/fs', @@ -2095,7 +3272,9 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'globset', 'ignore', 'libc', + 'regex', 'sha2', + 'similar', 'which', 'win32job', 'windows', @@ -2111,6 +3290,8 @@ test('services-core capability profiles keep heavy owners out of the empty profi ); const sourceContracts = sourceRule?.patterns.map((pattern) => pattern.regex.source).join('\n') ?? ''; for (const moduleName of [ + 'diagnostics', + 'diff', 'filesystem', 'json_store', 'managed_runtime', @@ -2172,6 +3353,186 @@ test('services-core Tokio capabilities stay owner-scoped', () => { ); }); +test('Services Core accepts only the reviewed feature-owned Tokio runtime graph', () => { + const validPackage = { + name: 'bitfun-services-core', + manifest_path: 'src/crates/services/services-core/Cargo.toml', + dependencies: [ + { + name: 'tokio', + kind: null, + optional: true, + features: [], + }, + ], + features: { + diff: ['dep:tokio', 'tokio/rt', 'tokio/time'], + filesystem: ['dep:tokio', 'tokio/fs', 'tokio/rt'], + 'json-io': ['dep:tokio', 'tokio/fs', 'tokio/rt', 'tokio/sync', 'tokio/time'], + 'local-storage': [ + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + 'tokio/sync', + 'tokio/time', + ], + permission: ['dep:tokio', 'tokio/rt'], + 'process-runtime': [ + 'dep:tokio', + 'tokio/io-util', + 'tokio/process', + 'tokio/rt', + 'tokio/time', + ], + 'workspace-instructions': ['dep:tokio', 'tokio/fs', 'tokio/io-util', 'tokio/rt'], + 'workspace-text-runtime': ['dep:tokio', 'tokio/rt'], + lsp: ['process-runtime', 'tokio/fs', 'tokio/io-util', 'tokio/sync'], + 'workspace-runtime': [ + 'process-runtime', + 'tokio/fs', + 'tokio/io-util', + 'tokio/sync', + ], + 'session-git': ['local-storage'], + }, + }; + + assert.deepEqual(findTokioDependencyFeatureViolations([validPackage]), []); +}); + +test('Services Core Tokio owners cannot be hidden behind an unreviewed alias', () => { + const invalidPackage = { + name: 'bitfun-services-core', + manifest_path: 'src/crates/services/services-core/Cargo.toml', + dependencies: [ + { + name: 'tokio', + kind: null, + optional: true, + features: [], + }, + ], + features: { + diff: ['dep:tokio', 'tokio/rt', 'tokio/time'], + filesystem: ['dep:tokio', 'tokio/fs', 'tokio/rt'], + 'json-io': ['dep:tokio', 'tokio/fs', 'tokio/rt', 'tokio/sync', 'tokio/time'], + 'local-storage': [ + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + 'tokio/sync', + 'tokio/time', + ], + permission: ['dep:tokio', 'tokio/rt'], + 'process-runtime': [ + 'dep:tokio', + 'tokio/io-util', + 'tokio/process', + 'tokio/rt', + 'tokio/time', + ], + 'workspace-instructions': ['dep:tokio', 'tokio/fs', 'tokio/io-util', 'tokio/rt'], + 'workspace-text-runtime': ['dep:tokio', 'tokio/rt'], + lsp: ['process-runtime', 'tokio/fs', 'tokio/io-util', 'tokio/sync'], + 'workspace-runtime': [ + 'process-runtime', + 'tokio/fs', + 'tokio/io-util', + 'tokio/sync', + ], + sneaky: ['filesystem', 'local-storage'], + 'sneaky-weak': ['tokio?/full'], + }, + }; + + const messages = findTokioDependencyFeatureViolations([invalidPackage]).map( + (violation) => violation.message, + ); + assert.ok( + messages.includes('bitfun-services-core:sneaky Tokio capabilities require an explicit owner contract'), + ); + assert.ok( + messages.includes('bitfun-services-core:sneaky-weak Tokio capabilities require an explicit owner contract'), + ); +}); + +test('Core feature-free Tokio capabilities stay limited to baseline path and state IO', () => { + const invalidPackage = { + name: 'bitfun-core', + manifest_path: 'src/crates/assembly/core/Cargo.toml', + dependencies: [ + { + name: 'tokio', + kind: null, + optional: false, + features: ['fs', 'io-util', 'macros', 'net', 'rt', 'sync', 'time'], + }, + ], + features: {}, + }; + + const messages = findTokioDependencyFeatureViolations([invalidPackage]).map( + (violation) => violation.message, + ); + assert.ok( + messages.some((message) => message.includes('unexpected base Tokio capabilities')), + 'Core must reject async runtime, networking, and timing capabilities in its feature-free profile', + ); +}); + +test('Core Tokio capabilities cannot hide behind an unreviewed owner feature', () => { + const invalidPackage = { + name: 'bitfun-core', + manifest_path: 'src/crates/assembly/core/Cargo.toml', + dependencies: [ + { + name: 'tokio', + kind: null, + optional: false, + features: ['fs', 'sync'], + }, + ], + features: { + 'agent-runtime': ['tokio/io-util', 'tokio/macros', 'tokio/rt', 'tokio/time'], + 'mcp-runtime': ['agent-runtime', 'tokio/rt-multi-thread'], + 'browser-control': ['tokio/net', 'tokio/rt', 'tokio/time'], + 'debug-log': ['tokio/macros', 'tokio/net', 'tokio/rt', 'tokio/time'], + lsp: ['tokio/macros'], + sneaky: ['agent-runtime', 'browser-control'], + }, + }; + + const messages = findTokioDependencyFeatureViolations([invalidPackage]).map( + (violation) => violation.message, + ); + assert.deepEqual(messages, [ + 'bitfun-core:sneaky Tokio capabilities require an explicit owner contract', + ]); +}); + +test('reviewed Tokio aggregates cannot declare runtime capabilities directly', () => { + const invalidPackage = { + name: 'bitfun-core', + manifest_path: 'src/crates/assembly/core/Cargo.toml', + dependencies: [{ name: 'tokio', kind: null, optional: false, features: ['fs', 'sync'] }], + features: { + 'agent-runtime': ['tokio/io-util', 'tokio/macros', 'tokio/rt', 'tokio/time'], + 'mcp-runtime': ['agent-runtime', 'tokio/rt-multi-thread'], + 'browser-control': ['tokio/net', 'tokio/rt', 'tokio/time'], + 'debug-log': ['tokio/macros', 'tokio/net', 'tokio/rt', 'tokio/time'], + lsp: ['tokio/macros'], + 'product-full': ['agent-runtime', 'tokio/net'], + }, + }; + + const messages = findTokioDependencyFeatureViolations([invalidPackage]).map( + (violation) => violation.message, + ); + assert.deepEqual(messages, [ + 'bitfun-core:product-full Tokio aggregate must compose reviewed owners instead of declaring Tokio capabilities directly', + ]); +}); + test('services-core Windows API capabilities stay feature-owned', async () => { const { findServicesCorePlatformDependencyFeatureViolations } = await import( './core-boundaries/cargo-dependency-boundaries.mjs' @@ -2245,3 +3606,388 @@ test('closed feature profiles reject product-full hidden behind a child feature' ], ); }); + +test('capability contract consumers may inherit empty defaults but must select reviewed features', async () => { + const cargoBoundaries = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + assert.equal( + typeof cargoBoundaries.findCapabilityContractConsumerViolations, + 'function', + 'Cargo boundary checker must expose the capability contract consumer policy', + ); + + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const agentTools = agentToolsCapabilityPackage(); + const pluginRuntimeClient = packageAt( + 'bitfun-plugin-runtime-client', + 'src/crates/execution/plugin-runtime-client/Cargo.toml', + [ + pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + }), + ], + ); + + const messages = findTestCapabilityViolations( + cargoBoundaries.findCapabilityContractConsumerViolations, + [ + runtimePorts, + agentTools, + pluginRuntimeClient, + ], + ).map((violation) => violation.message); + + assert.doesNotMatch(messages.join('\n'), /default-features = false/); + assert.ok(messages.some((message) => /plugin-runtime/.test(message))); +}); + +test('unreviewed consumers cannot add capability contract dependency edges', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const agentTools = agentToolsCapabilityPackage(); + const unreviewed = packageAt( + 'unreviewed-host', + 'src/apps/unreviewed-host/Cargo.toml', + [ + pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + target: 'cfg(windows)', + usesDefaultFeatures: false, + features: ['agent-api'], + }), + ], + ); + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts, agentTools, unreviewed], + capabilityContractDependencyRules.slice(0, 2), + ).map( + (violation) => violation.message, + ); + assert.equal(messages.length, 1, messages.join('\n')); + assert.match(messages[0], /unreviewed consumer/); +}); + +test('capability contract edge policy rejects alias, weak, optional, and non-normal widening', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const validDependency = pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + usesDefaultFeatures: false, + features: ['plugin-runtime'], + }); + const mutations = [ + { label: 'renamed alias forwarding', dependency: { ...validDependency, rename: 'ports' }, features: { sneaky: ['ports/agent-api'] }, expected: /sneaky.*unreviewed.*forwarding/ }, + { label: 'weak alias forwarding', dependency: { ...validDependency, rename: 'ports' }, features: { sneaky: ['ports?/agent-api'] }, expected: /sneaky.*unreviewed.*forwarding/ }, + { label: 'optional edge', dependency: { ...validDependency, optional: true }, features: {}, expected: /unreviewed.*dependency edge/ }, + { label: 'dev edge', dependency: { ...validDependency, kind: 'dev' }, features: {}, expected: /unreviewed.*dependency edge/ }, + { label: 'build edge', dependency: { ...validDependency, kind: 'build' }, features: {}, expected: /unreviewed.*dependency edge/ }, + { label: 'target edge', dependency: { ...validDependency, target: 'cfg(windows)' }, features: {}, expected: /unreviewed.*dependency edge/ }, + ]; + + for (const mutation of mutations) { + const consumer = { + ...packageAt( + 'bitfun-plugin-runtime-client', + 'src/crates/execution/plugin-runtime-client/Cargo.toml', + [mutation.dependency], + ), + features: mutation.features, + }; + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts, consumer], + ).map( + (violation) => violation.message, + ); + assert.ok( + messages.some((message) => mutation.expected.test(message)), + `${mutation.label} must not widen the reviewed capability contract`, + ); + } +}); + +test('capability contract targets require an explicit empty default feature', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + delete runtimePorts.features.default; + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts], + ).map( + (violation) => violation.message, + ); + assert.ok(messages.some((message) => /default feature must stay empty/.test(message))); +}); + +test('capability contract optional activators reject unreviewed dep aliases', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const agentTools = agentToolsCapabilityPackage(); + const dependency = pathDependency('src/crates/execution/tool-contracts', { + name: 'bitfun-agent-tools', + rename: 'tools_contract', + optional: true, + usesDefaultFeatures: false, + }); + const consumer = { + ...packageAt( + 'bitfun-acp', + 'src/crates/interfaces/acp/Cargo.toml', + [dependency], + ), + features: { + client: ['tools_contract/acp-bridge'], + server: ['dep:tools_contract'], + sneaky: ['tools_contract'], + }, + }; + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [agentTools, consumer], + ).map( + (violation) => violation.message, + ); + assert.ok(messages.some((message) => /sneaky.*unreviewed.*activation/.test(message))); + assert.doesNotMatch(messages.join('\n'), /server.*unreviewed.*activation/); +}); + +test('capability contract consumers cannot remove reviewed forwarding or activation', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const integrations = { + ...packageAt( + 'bitfun-services-integrations', + 'src/crates/services/services-integrations/Cargo.toml', + [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + git: [], + 'remote-connect': [ + 'bitfun-runtime-ports/agent-api', + 'bitfun-runtime-ports/remote-workspace-ports', + ], + 'remote-ssh': [ + 'bitfun-runtime-ports/remote-exec-port', + 'bitfun-runtime-ports/remote-workspace-ports', + 'bitfun-runtime-ports/workspace-ports', + ], + 'remote-ssh-concrete': ['dep:bitfun-runtime-ports'], + 'script-tool-runtime': ['bitfun-runtime-ports/script-tool-runtime'], + }, + }; + const servicesCore = { + ...packageAt( + 'bitfun-services-core', + 'src/crates/services/services-core/Cargo.toml', + [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + permission: [], + 'workspace-runtime': [ + 'dep:bitfun-runtime-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/workspace-ports', + ], + }, + }; + + const messages = findTestCapabilityViolations(findCapabilityContractConsumerViolations, [ + runtimePorts, + integrations, + servicesCore, + ]).map((violation) => violation.message); + + assert.ok(messages.some((message) => /bitfun-services-integrations:git.*missing reviewed.*git-port forwarding/.test(message))); + assert.ok(messages.some((message) => /bitfun-services-core:permission.*missing reviewed.*activation/.test(message))); +}); + +test('capability contract targets cannot be removed or replaced by a same-name package', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const reviewedConsumer = packageAt( + 'bitfun-plugin-runtime-client', + 'src/crates/execution/plugin-runtime-client/Cargo.toml', + [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + usesDefaultFeatures: false, + features: ['plugin-runtime'], + })], + ); + + const missingTargetMessages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [reviewedConsumer], + ).map((violation) => violation.message); + assert.ok(missingTargetMessages.some((message) => + /bitfun-runtime-ports managed target.*missing/.test(message))); + + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + reviewedConsumer.dependencies[0] = { + ...reviewedConsumer.dependencies[0], + path: null, + source: 'registry+https://github.com/rust-lang/crates.io-index', + }; + const spoofedTargetMessages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts, reviewedConsumer], + ).map((violation) => violation.message); + assert.ok(spoofedTargetMessages.some((message) => /managed internal path/.test(message))); + + const vendorRuntimePorts = { + ...runtimePorts, + manifest_path: join( + TEST_ROOT, + 'vendor', + 'src', + 'crates', + 'contracts', + 'runtime-ports', + 'Cargo.toml', + ), + }; + reviewedConsumer.dependencies[0] = { + ...reviewedConsumer.dependencies[0], + path: join(TEST_ROOT, 'vendor', 'src', 'crates', 'contracts', 'runtime-ports'), + source: null, + }; + const vendorTargetMessages = findCapabilityContractConsumerViolations( + [vendorRuntimePorts, reviewedConsumer], + [capabilityContractDependencyRules[0]], + { root: TEST_ROOT }, + ).map((violation) => violation.message); + assert.ok(vendorTargetMessages.some((message) => /managed target.*missing/.test(message))); +}); + +test('capability contract target feature graphs stay exact', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + runtimePorts.features['git-port'] = ['plugin-runtime']; + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts], + ).map( + (violation) => violation.message, + ); + assert.ok(messages.some((message) => /git-port.*feature graph must stay exact/.test(message))); +}); + +test('unreviewed local feature aliases cannot wrap reviewed capability owners', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const agentTools = agentToolsCapabilityPackage(); + const acp = { + ...packageAt( + 'bitfun-acp', + 'src/crates/interfaces/acp/Cargo.toml', + [pathDependency('src/crates/execution/tool-contracts', { + name: 'bitfun-agent-tools', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + default: ['client', 'server'], + client: ['bitfun-agent-tools/acp-bridge'], + server: ['dep:bitfun-agent-tools'], + sneakyClient: ['client'], + sneakyServer: ['server'], + }, + }; + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [agentTools, acp], + ).map( + (violation) => violation.message, + ); + assert.ok(messages.some((message) => /sneakyClient.*unreviewed.*aggregate/.test(message))); + assert.ok(messages.some((message) => /sneakyServer.*unreviewed.*aggregate/.test(message))); + assert.doesNotMatch(messages.join('\n'), /default.*unreviewed.*aggregate/); +}); + +test('capability contract consumers cannot remove reviewed dependency edges', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const pluginRuntimeClient = packageAt( + 'bitfun-plugin-runtime-client', + 'src/crates/execution/plugin-runtime-client/Cargo.toml', + ); + const opencodeAdapter = packageAt( + 'bitfun-opencode-adapter', + 'src/crates/adapters/opencode-adapter/Cargo.toml', + [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + usesDefaultFeatures: false, + features: ['plugin-runtime'], + })], + ); + + const messages = findTestCapabilityViolations(findCapabilityContractConsumerViolations, [ + runtimePorts, + pluginRuntimeClient, + opencodeAdapter, + ]).map((violation) => violation.message); + assert.ok(messages.some((message) => /bitfun-plugin-runtime-client.*missing reviewed.*normal.*edge/.test(message))); + assert.ok(messages.some((message) => /bitfun-opencode-adapter.*missing reviewed.*dev.*edge/.test(message))); +}); diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index c53371815e..7ea018e46a 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -251,6 +251,56 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => assert.equal(cache?.with?.['cache-on-failure'], trustedMain); } + const rustCache = rustJob.steps.find((step) => + step.uses?.startsWith('swatinem/rust-cache@'), + ); + assert.equal( + rustCache?.with?.['cache-directories'], + undefined, + 'Rust cache cleanup must not own native libraries stored under target', + ); + + const restoreSherpaCache = rustJob.steps.find( + (step) => step.name === 'Restore Sherpa native libraries', + ); + const repairSherpaState = rustJob.steps.find( + (step) => step.name === 'Repair missing Sherpa native state', + ); + const checkCompilation = rustJob.steps.find( + (step) => step.name === 'Check compilation', + ); + const saveSherpaCache = rustJob.steps.find( + (step) => step.name === 'Save Sherpa native libraries', + ); + const sherpaCacheKey = + 'sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static'; + + assert.equal(restoreSherpaCache?.uses, 'actions/cache/restore@v5'); + assert.equal(restoreSherpaCache?.with?.path, 'target/sherpa-onnx-prebuilt'); + assert.equal(restoreSherpaCache?.with?.key, sherpaCacheKey); + assert.match( + repairSherpaState?.run ?? '', + /rm -rf target\/sherpa-onnx-prebuilt/, + ); + assert.match(repairSherpaState?.run ?? '', /cargo clean -p sherpa-onnx-sys/); + assert.equal(saveSherpaCache?.uses, 'actions/cache/save@v5'); + assert.equal(saveSherpaCache?.with?.path, 'target/sherpa-onnx-prebuilt'); + assert.equal(saveSherpaCache?.with?.key, sherpaCacheKey); + assert.equal( + saveSherpaCache?.if, + "github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.sherpa-native-cache.outputs.cache-hit != 'true'", + ); + assert.ok( + rustJob.steps.indexOf(restoreSherpaCache) < + rustJob.steps.indexOf(checkCompilation), + 'Sherpa native libraries must be restored before cargo check', + ); + assert.ok( + rustJob.steps.indexOf(checkCompilation) < + rustJob.steps.indexOf(saveSherpaCache), + 'Sherpa native libraries must be saved before rust-cache post cleanup', + ); + const commandByStep = new Map( rustJob.steps.map((step) => [step.name, step.run]), ); @@ -327,22 +377,169 @@ test('stages unique release asset names before publishing', () => { ), ); const steps = workflow.jobs['upload-release-assets'].steps; - const stagingIndex = steps.findIndex( - (step) => step.name === 'Stage uniquely named release assets', - ); + const stagingIndexes = [ + steps.findIndex((step) => step.name === 'Stage stable release assets'), + steps.findIndex((step) => step.name === 'Stage beta release assets'), + ]; const uploadIndex = steps.findIndex((step) => step.name === 'Upload to release'); - assert.notEqual(stagingIndex, -1); + assert.equal(stagingIndexes.every((index) => index >= 0), true); assert.notEqual(uploadIndex, -1); - assert.ok(stagingIndex < uploadIndex); + for (const stagingIndex of stagingIndexes) { + assert.ok(stagingIndex < uploadIndex); + assert.match( + steps[stagingIndex].run, + /node scripts\/stage-github-release-assets\.mjs/, + ); + assert.doesNotMatch( + steps[stagingIndex].run, + /release-assets\/\*\*\/\*\.sig(?:\s|\\)/, + 'raw updater signatures have colliding names across macOS architectures', + ); + } + assert.equal(steps[uploadIndex].with.files, 'release-upload-assets/*'); +}); + +test('Desktop packaging keeps beta identity explicit and stable-safe', () => { + const workflow = yaml.parse( + readFileSync( + path.join(repoRoot, '.github/workflows/desktop-package.yml'), + 'utf8', + ), + ); + const inputs = workflow.on.workflow_dispatch.inputs; + assert.deepEqual(inputs.release_channel.options, ['stable', 'beta']); + assert.equal(inputs.release_channel.default, 'stable'); + + const prepareStep = workflow.jobs.prepare.steps.find( + (step) => step.name === 'Resolve version metadata', + ); + assert.match(prepareStep.run, /GITHUB_REPOSITORY.*GCWing\/BitFun/); + assert.match(prepareStep.run, /merge-base --is-ancestor/); + assert.match(prepareStep.run, /rev-parse --verify --quiet/); + + const packageJob = workflow.jobs.package; + assert.equal( + packageJob.env.BITFUN_RELEASE_CHANNEL, + '${{ needs.prepare.outputs.release_channel }}', + ); + assert.match(packageJob.env.TAURI_UPDATER_ENDPOINT, /github\.repository/); + assert.match(packageJob.env.TAURI_UPDATER_ENDPOINT, /channel-beta/); + assert.match(packageJob.env.BITFUN_RELEASE_PUBKEY, /BITFUN_RELEASE_PUBKEY/); + const appleSetupIndex = packageJob.steps.findIndex( + (step) => step.name === 'Configure Apple Developer ID signing and notarization', + ); + const desktopBuildIndex = packageJob.steps.findIndex( + (step) => step.name === 'Build desktop app', + ); + const appleVerifyIndex = packageJob.steps.findIndex( + (step) => step.name === 'Verify Apple signature and notarization', + ); + assert.ok( + appleSetupIndex >= 0 && + appleSetupIndex < desktopBuildIndex && + desktopBuildIndex < appleVerifyIndex, + 'Apple credentials must be configured before packaging and verified afterwards', + ); + assert.equal(packageJob.steps[appleSetupIndex].if, "runner.os == 'macOS'"); + assert.equal( + packageJob.steps[appleSetupIndex].env.BITFUN_REQUIRE_APPLE_SIGNING, + '${{ needs.prepare.outputs.upload_to_release }}', + ); + assert.equal(packageJob.steps[appleVerifyIndex].if, "runner.os == 'macOS'"); + const patchIndex = packageJob.steps.findIndex( + (step) => step.name === 'Project beta build version', + ); + const verifyIndex = packageJob.steps.findIndex( + (step) => step.name === 'Verify release version metadata', + ); + assert.ok(patchIndex >= 0 && patchIndex < verifyIndex); + assert.equal( + packageJob.steps[patchIndex].if, + "needs.prepare.outputs.release_channel == 'beta'", + ); + + const uploadSteps = workflow.jobs['upload-release-assets'].steps; + const release = uploadSteps.find((step) => step.name === 'Upload to release'); + assert.equal( + release.with.prerelease, + "${{ needs.prepare.outputs.release_channel == 'beta' }}", + ); + const verifyIndexPublished = uploadSteps.findIndex( + (step) => step.name === 'Verify published updater manifest', + ); + const promoteIndex = uploadSteps.findIndex( + (step) => step.name === 'Publish beta channel manifest', + ); + assert.ok(verifyIndexPublished >= 0 && verifyIndexPublished < promoteIndex); + assert.match(workflow.jobs['linux-binaries'].if, /release_channel == 'stable'/); + assert.equal( + uploadSteps.find((step) => step.name === 'Stage beta release assets').if, + "needs.prepare.outputs.release_channel == 'beta'", + ); assert.match( - steps[stagingIndex].run, - /node scripts\/stage-github-release-assets\.mjs/, + uploadSteps.find((step) => step.name === 'Generate updater manifest').run, + /github\.repository/, ); - assert.doesNotMatch( - steps[stagingIndex].run, - /release-assets\/\*\*\/\*\.sig(?:\s|\\)/, - 'raw updater signatures have colliding names across macOS architectures', + const signingStep = uploadSteps.find( + (step) => step.name === 'Sign installer packages', ); - assert.equal(steps[uploadIndex].with.files, 'release-upload-assets/*'); + assert.match(signingStep.run, /write-minisign-public-key\.mjs/); + assert.doesNotMatch(signingStep.run, /BITFUN_SIGNING_PUBKEY.*base64 -d/); + const promotionStep = uploadSteps.find( + (step) => step.name === 'Resolve beta channel promotion', + ); + assert.doesNotMatch(promotionStep.run, /current\.beta\.json \|\| true/); + assert.match(promotionStep.run, /case "\$\{channel_status\}" in/); + assert.match(promotionStep.run, /404\)/); + assert.match(promotionStep.run, /GitHub API returned/); + const publishStep = uploadSteps.find( + (step) => step.name === 'Publish beta channel manifest', + ); + assert.equal( + publishStep.env.CHANNEL_EXISTS, + '${{ steps.beta-channel.outputs.channel_exists }}', + ); +}); + +test('beta publishing cannot advance the Relay latest image tag', () => { + const workflow = yaml.parse( + readFileSync( + path.join(repoRoot, '.github/workflows/desktop-package.yml'), + 'utf8', + ), + ); + const imageTags = workflow.jobs['publish-relay-image'].steps.find( + (step) => step.name === 'Resolve image tags', + ); + assert.equal( + imageTags.env.RELEASE_CHANNEL, + '${{ needs.prepare.outputs.release_channel }}', + ); + assert.match(imageTags.run, /RELEASE_CHANNEL.*stable/); + assert.doesNotMatch(imageTags.run, /RELEASE_PRERELEASE/); +}); + +test('nightly and beta use the shared build-version projection', () => { + const nightly = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/nightly.yml'), 'utf8'), + ); + const patch = nightly.jobs.package.steps.find( + (step) => step.name === 'Patch nightly version', + ); + assert.match(patch.run, /node scripts\/set-build-version\.mjs/); + assert.equal(nightly.jobs.package.env.BITFUN_RELEASE_CHANNEL, 'nightly'); + assert.equal( + nightly.jobs.package.env.TAURI_UPDATER_ENDPOINT, + 'https://github.com/GCWing/BitFun/releases/latest/download/latest.json', + ); + assert.equal( + nightly.jobs.package.env.TAURI_UPDATER_FALLBACK_ENDPOINT, + 'https://openbitfun.com/release/latest.json', + ); + assert.equal(nightly.jobs.package.env.BITFUN_ENABLE_UPDATER_ARTIFACTS, undefined); + const signingStep = nightly.jobs['publish-nightly'].steps.find( + (step) => step.name === 'Sign installer packages', + ); + assert.match(signingStep.run, /write-minisign-public-key\.mjs/); }); diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs index 2ccdeff49b..a6be6621b4 100644 --- a/scripts/check-harmonyos-architecture.mjs +++ b/scripts/check-harmonyos-architecture.mjs @@ -44,6 +44,7 @@ const allPages = filesUnder(pagesRoot); const services = filesUnder(path.join(etsRoot, 'services')); const components = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}components${path.sep}`)); const viewmodels = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}viewmodel${path.sep}`)); +const stateFiles = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}state${path.sep}`)); const serviceToPages = services .filter((file) => imports(file).some((spec) => spec === 'pages' || spec.startsWith('pages/'))) @@ -54,6 +55,9 @@ const componentToViewmodel = components const viewmodelToComponents = viewmodels .filter((file) => imports(file).some((spec) => spec === 'pages/components' || spec.startsWith('pages/components/'))) .map(relative); +const stateToComponents = stateFiles + .filter((file) => imports(file).some((spec) => spec === 'pages/components' || spec.startsWith('pages/components/'))) + .map(relative); const v1Components = allPages .filter((file) => /^\s*@Component\s*$/m.test(fs.readFileSync(file, 'utf8'))) .map(relative); @@ -98,10 +102,16 @@ const requiredPresentationFiles = [ 'components/AppRootOverlaySurfaces.ets', 'components/ChatMessageChrome.ets', 'components/ConnectManualPairingOverlay.ets', + 'components/ConversationHeader.ets', 'components/ConversationRouteSurface.ets', 'components/ToolInteractionPanels.ets', 'components/WideConversationHost.ets', - 'components/remote/RemoteSurfaceHost.ets' + 'components/remote/RemoteSurfaceHost.ets', + 'policy/ConversationHeaderPolicy.ets', + 'viewmodel/ConversationRuntime.ets', + 'viewmodel/RemoteTranscriptController.ets', + 'viewmodel/RemoteCreateFlowController.ets', + 'viewmodel/VisibleConversationController.ets' ]; const missingPresentationFiles = requiredPresentationFiles .filter((file) => !fs.existsSync(path.join(pagesRoot, file))); @@ -132,7 +142,6 @@ const extractedCloudAccountMethods = [ 'loginCloudAccount', 'restoreCloudAccountSession', 'loadGeneralChatAccountModels', - 'syncCloudAccount', 'applyCloudAccountSession', 'logoutCloudAccount', 'listCloudAccountDevices', @@ -269,6 +278,7 @@ const expected = { serviceToPages: [], componentToViewmodel: [], viewmodelToComponents: [], + stateToComponents: [], v1Components: [], positionalActionConstructors: [], duplicatedConversationTraceFields: [], @@ -293,6 +303,7 @@ const actual = { serviceToPages, componentToViewmodel, viewmodelToComponents, + stateToComponents, v1Components, positionalActionConstructors, duplicatedConversationTraceFields, @@ -325,6 +336,12 @@ if (appRootPresentationLines > 500) { failed = true; console.error(`AppRootPresentation line budget exceeded: expected <=500, actual=${appRootPresentationLines}`); } +const conversationControllerFile = path.join(pagesRoot, 'viewmodel/ConversationController.ets'); +const conversationControllerLines = fs.readFileSync(conversationControllerFile, 'utf8').split(/\r?\n/).length - 1; +if (conversationControllerLines > 400) { + failed = true; + console.error(`ConversationController line budget exceeded: expected <=400, actual=${conversationControllerLines}`); +} for (const [file, budget] of componentLineBudgets) { const source = fs.readFileSync(path.join(pagesRoot, file), 'utf8'); const lineCount = source.split(/\r?\n/).length - 1; diff --git a/scripts/ci/setup-macos-signing.sh b/scripts/ci/setup-macos-signing.sh new file mode 100755 index 0000000000..17264e4b36 --- /dev/null +++ b/scripts/ci/setup-macos-signing.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +required=( + APPLE_CERTIFICATE + APPLE_CERTIFICATE_PASSWORD + APPLE_SIGNING_IDENTITY + APPLE_API_ISSUER + APPLE_API_KEY + APPLE_API_PRIVATE_KEY + KEYCHAIN_PASSWORD +) + +missing=() +for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + missing+=("${name}") + fi +done + +if [[ "${#missing[@]}" -gt 0 ]]; then + if [[ "${BITFUN_REQUIRE_APPLE_SIGNING:-false}" == "true" ]]; then + printf 'Apple signing is required, but these secrets are missing: %s\n' "${missing[*]}" >&2 + exit 1 + fi + printf 'Apple signing is not fully configured; leaving this non-release macOS build unsigned. Missing: %s\n' "${missing[*]}" + exit 0 +fi + +signing_dir="${RUNNER_TEMP}/bitfun-apple-signing" +certificate_path="${signing_dir}/developer-id.p12" +api_key_path="${signing_dir}/AuthKey_${APPLE_API_KEY}.p8" +keychain_path="${RUNNER_TEMP}/bitfun-signing.keychain-db" + +mkdir -p "${signing_dir}" +chmod 700 "${signing_dir}" +printf '%s' "${APPLE_CERTIFICATE}" | base64 --decode >"${certificate_path}" +printf '%s' "${APPLE_API_PRIVATE_KEY}" >"${api_key_path}" +chmod 600 "${certificate_path}" "${api_key_path}" + +security create-keychain -p "${KEYCHAIN_PASSWORD}" "${keychain_path}" +security set-keychain-settings -lut 21600 "${keychain_path}" +security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${keychain_path}" +security import "${certificate_path}" \ + -k "${keychain_path}" \ + -P "${APPLE_CERTIFICATE_PASSWORD}" \ + -T /usr/bin/codesign \ + -T /usr/bin/security +security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "${KEYCHAIN_PASSWORD}" \ + "${keychain_path}" + +curl --fail --location --silent --show-error \ + 'https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer' \ + --output "${signing_dir}/DeveloperIDG2CA.cer" +security import "${signing_dir}/DeveloperIDG2CA.cer" -k "${keychain_path}" + +security list-keychains -d user -s "${keychain_path}" "${HOME}/Library/Keychains/login.keychain-db" +security default-keychain -d user -s "${keychain_path}" +security find-identity -v -p codesigning "${keychain_path}" + +if ! security find-identity -v -p codesigning "${keychain_path}" | grep -Fq "${APPLE_SIGNING_IDENTITY}"; then + echo "The imported certificate does not provide ${APPLE_SIGNING_IDENTITY}." >&2 + exit 1 +fi + +{ + echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}" + echo "APPLE_API_KEY=${APPLE_API_KEY}" + echo "APPLE_API_KEY_PATH=${api_key_path}" + echo "APPLE_SIGNING_IDENTITY=${APPLE_SIGNING_IDENTITY}" + echo "BITFUN_APPLE_SIGNING_CONFIGURED=true" +} >>"${GITHUB_ENV}" + +echo "Apple Developer ID signing and notarization credentials are ready." diff --git a/scripts/ci/verify-macos-signing.sh b/scripts/ci/verify-macos-signing.sh new file mode 100755 index 0000000000..4513fa57a5 --- /dev/null +++ b/scripts/ci/verify-macos-signing.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="${1:?usage: verify-macos-signing.sh }" +if [[ "${BITFUN_APPLE_SIGNING_CONFIGURED:-false}" != "true" ]]; then + echo "Apple signing is not configured; skipping macOS signature verification." + exit 0 +fi + +bundle_dir="target/${target}/release/bundle" + +shopt -s nullglob +apps=("${bundle_dir}/macos/"*.app) +dmgs=("${bundle_dir}/dmg/"*.dmg) + +if [[ "${#apps[@]}" -eq 0 || "${#dmgs[@]}" -eq 0 ]]; then + echo "Expected one or more macOS app and DMG bundles under ${bundle_dir}." >&2 + exit 1 +fi + +for app in "${apps[@]}"; do + codesign --verify --deep --strict --verbose=2 "${app}" + while IFS= read -r -d '' candidate; do + if ! file -b "${candidate}" | grep -q 'Mach-O'; then + continue + fi + signature_details="$(codesign -dv --verbose=4 "${candidate}" 2>&1)" + grep -Fq 'Authority=Developer ID Application:' <<<"${signature_details}" + grep -Eq 'flags=.*\(runtime\)' <<<"${signature_details}" + grep -Fq 'Timestamp=' <<<"${signature_details}" + done < <(find "${app}" -type f -print0) + xcrun stapler validate "${app}" + spctl --assess --type execute --verbose=4 "${app}" +done + +for dmg in "${dmgs[@]}"; do + xcrun notarytool submit "${dmg}" \ + --issuer "${APPLE_API_ISSUER}" \ + --key-id "${APPLE_API_KEY}" \ + --key "${APPLE_API_KEY_PATH}" \ + --wait + xcrun stapler staple "${dmg}" + xcrun stapler validate "${dmg}" + spctl --assess --type open --context context:primary-signature --verbose=4 "${dmg}" +done + +echo "Verified Developer ID signatures, notarization tickets, and Gatekeeper acceptance." diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index b76ccc00ae..523c3ab192 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -2,7 +2,13 @@ import { readFileSync, readdirSync } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { servicesReqwestOwnerFeatures } from './rules/feature-rules.mjs'; +import { + acpClientCoreFeatures, + acpServerCoreFeatures, + capabilityContractDependencyRules, + guardedEmptyInternalDefaultManifestPaths, + servicesReqwestOwnerFeatures, +} from './rules/feature-rules.mjs'; const SKIPPED_DIRECTORIES = new Set([ '.git', @@ -127,7 +133,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ['browser-control', ['time']], ['canvas-runtime', ['fs']], ['debug-log', ['rt']], - ['deep-research', ['fs']], + ['deep-research', []], ['git', ['fs', 'io-util', 'macros', 'rt', 'time']], ['file-watch', ['rt', 'sync']], ['function-agents', ['fs', 'io-util', 'macros', 'rt', 'time']], @@ -146,18 +152,46 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ]); const SERVICES_CORE_TOKIO_FEATURES = new Map([ - ['filesystem', ['fs']], - ['json-io', ['fs', 'sync']], - ['local-storage', ['fs', 'sync']], - ['process-runtime', ['io-util', 'process']], - ['workspace-instructions', ['fs', 'io-util']], - ['lsp', ['fs', 'io-util', 'process', 'sync']], - ['workspace-runtime', ['fs', 'io-util', 'process', 'sync']], + ['diff', ['rt', 'time']], + ['filesystem', ['fs', 'rt']], + ['json-io', ['fs', 'rt', 'sync', 'time']], + ['local-storage', ['fs', 'rt', 'sync', 'time']], + ['permission', ['rt']], + ['process-runtime', ['io-util', 'process', 'rt', 'time']], + ['workspace-instructions', ['fs', 'io-util', 'rt']], + ['workspace-text-runtime', ['rt']], + ['lsp', ['fs', 'io-util', 'process', 'rt', 'sync', 'time']], + ['workspace-runtime', ['fs', 'io-util', 'process', 'rt', 'sync', 'time']], +]); +const SERVICES_CORE_BASE_TOKIO_FEATURES = []; +const SERVICES_INTEGRATIONS_TOKIO_AGGREGATES = new Set(['product-full']); +const SERVICES_CORE_TOKIO_AGGREGATES = new Set(['session-git']); +const CORE_TOKIO_FEATURES = new Map([ + ['agent-runtime', ['io-util', 'macros', 'rt', 'time']], + ['mcp-runtime', ['io-util', 'macros', 'rt', 'rt-multi-thread', 'time']], + ['browser-control', ['net', 'rt', 'time']], + ['debug-log', ['macros', 'net', 'rt', 'time']], + ['lsp', ['macros']], +]); +const CORE_TOKIO_AGGREGATES = new Set([ + 'external-sources', + 'plugin-runtime', + 'product-full', + 'remote-connect', + 'tools-browser-web', + 'tools-mcp', +]); +const AGENT_RUNTIME_TOKIO_FEATURES = new Map([ + ['native-hook-runtime', ['io-util', 'macros', 'process', 'rt', 'time']], + ['agent-runtime', ['io-util', 'macros', 'process', 'rt', 'sync', 'time']], ]); -const SERVICES_CORE_BASE_TOKIO_FEATURES = ['rt', 'time']; const TOKIO_DEPENDENCY_POLICY_EXCLUDED_PACKAGES = new Set(); +function tokioCapabilityReference(value) { + return value.match(/^tokio\??\/(.+)$/)?.[1]; +} + function effectiveTokioCapabilities(feature, featureGraph, visiting = new Set()) { if (visiting.has(feature)) { return new Set(); @@ -166,8 +200,9 @@ function effectiveTokioCapabilities(feature, featureGraph, visiting = new Set()) const capabilities = new Set(); for (const value of featureGraph[feature] ?? []) { - if (value.startsWith('tokio/')) { - capabilities.add(value.slice('tokio/'.length)); + const capability = tokioCapabilityReference(value); + if (capability) { + capabilities.add(capability); } else if (Object.hasOwn(featureGraph, value)) { for (const capability of effectiveTokioCapabilities(value, featureGraph, visiting)) { capabilities.add(capability); @@ -179,7 +214,7 @@ function effectiveTokioCapabilities(feature, featureGraph, visiting = new Set()) return capabilities; } -function findOwnedTokioFeatureViolations(pkg, ownerProfiles) { +function findOwnedTokioFeatureViolations(pkg, ownerProfiles, aggregateFeatures = new Set()) { const violations = []; const featureGraph = pkg.features ?? {}; @@ -217,7 +252,17 @@ function findOwnedTokioFeatureViolations(pkg, ownerProfiles) { if (ownerProfiles.has(feature)) { continue; } - if (values.some((value) => value.startsWith('tokio/'))) { + if (aggregateFeatures.has(feature)) { + if (values.some((value) => tokioCapabilityReference(value))) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${feature} Tokio aggregate must compose reviewed owners instead of declaring Tokio capabilities directly`, + }); + } + continue; + } + if (effectiveTokioCapabilities(feature, featureGraph).size > 0) { violations.push({ path: pkg.manifest_path, line: 1, @@ -230,7 +275,11 @@ function findOwnedTokioFeatureViolations(pkg, ownerProfiles) { } export function findServicesIntegrationsTokioFeatureViolations(pkg) { - return findOwnedTokioFeatureViolations(pkg, SERVICES_INTEGRATIONS_TOKIO_FEATURES); + return findOwnedTokioFeatureViolations( + pkg, + SERVICES_INTEGRATIONS_TOKIO_FEATURES, + SERVICES_INTEGRATIONS_TOKIO_AGGREGATES, + ); } function reqwestDependencyFeatureReferences(references) { @@ -243,36 +292,37 @@ function reqwestDependencyFeatureReferences(references) { ); } -const REQWEST_TRANSPORT_FEATURES = [ - 'form', - 'http2', - 'json', - 'multipart', - 'query', - 'stream', -]; const REQWEST_PACKAGE_PROFILES = new Map([ - ['bitfun-core', { dependencyFeatures: REQWEST_TRANSPORT_FEATURES, optional: true }], + ['bitfun-core', { dependencyFeatures: [], optional: true }], ['bitfun-services-integrations', { - dependencyFeatures: REQWEST_TRANSPORT_FEATURES, + dependencyFeatures: ['http2'], optional: true, servicesOwners: true, }], ['bitfun-ai-adapters', { - dependencyFeatures: [...REQWEST_TRANSPORT_FEATURES, 'rustls', 'socks'], + dependencyFeatures: ['http2', 'json', 'rustls', 'socks', 'stream'], + optional: false, + allowedPackageFeatureRefs: new Set(['reqwest/form']), + requiredPackageFeatureRefs: new Map([ + ['subscription-auth', new Set(['reqwest/form'])], + ]), + }], + ['bitfun-cli', { + dependencyFeatures: ['http2', 'rustls', 'stream'], + optional: false, + }], + ['bitfun-desktop', { + dependencyFeatures: ['http2', 'json', 'query', 'rustls', 'stream'], + optional: false, + }], + ['bitfun-miniapp-market-service', { + dependencyFeatures: ['form', 'http2', 'json', 'rustls'], optional: false, - allowedPackageFeatureRefs: new Set(['reqwest/rustls']), }], - ...[ - 'bitfun-cli', - 'bitfun-desktop', - 'bitfun-miniapp-market-service', - 'bitfun-skin-market-service', - ].map((packageName) => [packageName, { - dependencyFeatures: [...REQWEST_TRANSPORT_FEATURES, 'rustls'], + ['bitfun-skin-market-service', { + dependencyFeatures: ['http2', 'json', 'rustls'], optional: false, - allowedPackageFeatureRefs: new Set(['reqwest/rustls']), - }]), + }], ]); function findReqwestPackageProfileViolations(pkg, profile) { @@ -356,6 +406,18 @@ function findReqwestPackageProfileViolations(pkg, profile) { } } } + for (const [featureName, requiredReferences] of profile.requiredPackageFeatureRefs ?? []) { + const actualReferences = new Set(pkg.features?.[featureName] ?? []); + for (const reference of requiredReferences) { + if (!actualReferences.has(reference)) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${featureName} is missing Reqwest feature reference ${reference}`, + }); + } + } + } } return violations; @@ -511,6 +573,20 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { const violations = []; const featureGraph = pkg.features ?? {}; const ownerFeatures = new Set(servicesReqwestOwnerFeatures); + const ownerFeatureReferences = new Map([ + ['announcement', ['reqwest/json']], + ['browser-control', ['reqwest/json']], + ['debug-log', ['reqwest/json']], + ['mcp', ['reqwest/json', 'reqwest/stream']], + ['miniapp-market', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], + ['miniapp-runtime', ['reqwest/stream']], + ['models-dev', ['reqwest/system-proxy']], + ['remote-connect', ['reqwest/json', 'reqwest/multipart', 'reqwest/query']], + ['remote-ssh-concrete', ['reqwest/stream']], + ['review-platform', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], + ['speech', ['reqwest/stream']], + ['web-tools', ['reqwest/json']], + ]); for (const featureName of servicesReqwestOwnerFeatures) { const references = featureGraph[featureName]; @@ -536,6 +612,15 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { message: `${pkg.name}:${featureName} is missing reqwest/rustls`, }); } + for (const reference of ownerFeatureReferences.get(featureName) ?? []) { + if (!references.includes(reference)) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${featureName} is missing Reqwest feature reference ${reference}`, + }); + } + } } for (const [featureName, references] of Object.entries(featureGraph)) { @@ -556,12 +641,15 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { }); continue; } + const allowedReferences = new Set([ + 'reqwest', + 'dep:reqwest', + 'reqwest/rustls', + ...(ownerFeatureReferences.get(featureName) ?? []), + ]); for (const reference of reqwestReferences) { if ( - reference !== 'reqwest' - && reference !== 'dep:reqwest' - && reference !== 'reqwest/rustls' - && !(featureName === 'models-dev' && reference === 'reqwest/system-proxy') + !allowedReferences.has(reference) ) { violations.push({ path: pkg.manifest_path, @@ -578,7 +666,11 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { export function findServicesCoreTokioFeatureViolations(pkg) { - return findOwnedTokioFeatureViolations(pkg, SERVICES_CORE_TOKIO_FEATURES); + return findOwnedTokioFeatureViolations( + pkg, + SERVICES_CORE_TOKIO_FEATURES, + SERVICES_CORE_TOKIO_AGGREGATES, + ); } export function findServicesCorePlatformDependencyFeatureViolations(packages) { @@ -629,9 +721,22 @@ export function findTokioDependencyFeatureViolations(packages) { const featureOwnedServicesCoreRuntime = pkg.name === 'bitfun-services-core' && (dependency.kind ?? null) === null; - if (featureOwnedServicesCoreRuntime) { + const featureOwnedCoreRuntime = + pkg.name === 'bitfun-core' + && (dependency.kind ?? null) === null; + const featureOwnedAgentRuntime = + pkg.name === 'bitfun-agent-runtime' + && (dependency.kind ?? null) === null; + if ( + featureOwnedIntegrationRuntime + || featureOwnedServicesCoreRuntime + || featureOwnedCoreRuntime + || featureOwnedAgentRuntime + ) { const actual = [...features].sort(); - const expected = [...SERVICES_CORE_BASE_TOKIO_FEATURES].sort(); + const expected = [...(featureOwnedCoreRuntime + ? ['fs', 'sync'] + : SERVICES_CORE_BASE_TOKIO_FEATURES)].sort(); const missing = expected.filter((feature) => !actual.includes(feature)); const unexpected = actual.filter((feature) => !expected.includes(feature)); if (missing.length > 0) { @@ -663,6 +768,19 @@ export function findTokioDependencyFeatureViolations(packages) { if (pkg.name === 'bitfun-services-core') { violations.push(...findServicesCoreTokioFeatureViolations(pkg)); } + if (pkg.name === 'bitfun-core') { + violations.push(...findOwnedTokioFeatureViolations( + pkg, + CORE_TOKIO_FEATURES, + CORE_TOKIO_AGGREGATES, + )); + } + if (pkg.name === 'bitfun-agent-runtime') { + violations.push(...findOwnedTokioFeatureViolations( + pkg, + AGENT_RUNTIME_TOKIO_FEATURES, + )); + } } return violations; @@ -770,53 +888,48 @@ export function findProductEntrypointCoreFeatureViolations( packages, { root, crateLayoutRules }, ) { + const coreCompatibilityReviewedFeatures = [ + 'agent-runtime', + 'document-read', + 'subscription-auth', + 'deep-research', + 'lsp', + 'external-sources', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', + ]; const reviewedCoreFeatureClosures = new Map([ ['bitfun-cli', [ - 'agent-runtime', + ...coreCompatibilityReviewedFeatures, 'remote-connect', - 'deep-research', - 'lsp', - 'external-sources', 'plugin-runtime', 'ssh-remote', - 'tools-basic', - 'tools-git', - 'tools-mcp', - 'tools-browser-web', - 'tools-computer-use', - 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', - 'tools-agent-control', - ]], - ['bitfun-acp', [ - 'agent-runtime', - 'deep-research', - 'lsp', - 'external-sources', - 'ssh-remote', - 'tools-basic', - 'tools-git', - 'tools-mcp', - 'tools-browser-web', - 'tools-computer-use', - 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', - 'tools-agent-control', ]], + ['bitfun-acp', [...new Set([...acpClientCoreFeatures, ...acpServerCoreFeatures])]], ['bitfun-app-server', [ 'external-sources', 'git', + 'i18n-runtime', 'remote-connect', ]], + ['bitfun-sdk-host-app', coreCompatibilityReviewedFeatures], ]); - const acpActiveCoreFeatures = [ + const fullProductCoreEntrypoints = new Set(['bitfun-desktop', 'bitfun-server']); + const fullProductCoreEntrypointsFound = new Set(); + const coreCompatibilityActiveFeatures = [ 'agent-runtime', 'ai-adapter-runtime', 'browser-control', 'canvas-runtime', 'deep-research', + 'document-read', 'external-sources', 'file-watch', 'filesystem', @@ -828,12 +941,11 @@ export function findProductEntrypointCoreFeatureViolations( 'plugin-source', 'process-runtime', 'product-capabilities', - 'remote-workspace', 'review-platform', 'runtime-services', 'scheduled-jobs', 'script-tool-runtime', - 'ssh-remote', + 'subscription-auth', 'terminal', 'tool-packs', 'tools-agent-control', @@ -850,9 +962,20 @@ export function findProductEntrypointCoreFeatureViolations( 'workspace-runtime', 'workspace-watch', ]; + const acpActiveCoreFeatures = [ + ...coreCompatibilityActiveFeatures, + 'remote-workspace', + 'ssh-remote', + ]; const reviewedActiveCoreFeatureClosures = new Map([ - ['bitfun-cli', [...acpActiveCoreFeatures, 'plugin-runtime', 'remote-connect']], + ['bitfun-cli', [ + ...acpActiveCoreFeatures, + 'i18n-runtime', + 'plugin-runtime', + 'remote-connect', + ]], ['bitfun-acp', acpActiveCoreFeatures], + ['bitfun-sdk-host-app', coreCompatibilityActiveFeatures], ['bitfun-app-server', [ 'agent-runtime', 'ai-adapter-runtime', @@ -860,6 +983,7 @@ export function findProductEntrypointCoreFeatureViolations( 'file-watch', 'filesystem', 'git', + 'i18n-runtime', 'local-storage', 'mcp-runtime', 'model-catalog', @@ -880,11 +1004,129 @@ export function findProductEntrypointCoreFeatureViolations( 'workspace-watch', ]], ]); + const reviewedForbiddenDependencyOwnerFeatures = new Map([ + ['bitfun-sdk-host-app', new Map([ + ['bitfun-services-integrations', [ + 'announcement', + 'debug-log', + 'function-agents', + 'product-full', + 'remote-connect', + 'remote-ssh', + 'remote-ssh-concrete', + ]], + ['bitfun-product-domains', ['function-agents', 'product-full']], + ['bitfun-services-core', ['dispatch-workspace']], + ])], + ]); const packageByManifest = new Map( packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), ); const violations = []; + const reviewedAcpRoleSelections = new Map([ + ['bitfun-cli', { + label: 'CLI', + requiredFeatures: ['client', 'server'], + }], + ['bitfun-desktop', { + label: 'Desktop', + requiredFeatures: ['client'], + }], + ]); + const acpPackage = packages.find((pkg) => pkg.name === 'bitfun-acp'); + if (acpPackage) { + const reviewedConsumersFound = new Set(); + for (const sourcePackage of packages) { + const declaredDependencies = (sourcePackage.dependencies ?? []).filter((candidate) => { + if (!candidate.path) { + return false; + } + return packageByManifest.get( + normalizedPath(join(candidate.path, 'Cargo.toml')), + )?.name === 'bitfun-acp'; + }); + const normalDependencies = declaredDependencies.filter( + (dependency) => dependency.kind === null, + ); + const rule = reviewedAcpRoleSelections.get(sourcePackage.name); + if (!rule) { + if (declaredDependencies.length > 0) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `bitfun-acp consumer ${sourcePackage.name} must register an explicit role selection`, + }); + } + continue; + } + if (declaredDependencies.length === 0) { + continue; + } + reviewedConsumersFound.add(sourcePackage.name); + const unconditionalDependencies = normalDependencies.filter( + (dependency) => dependency.target === null && dependency.optional !== true, + ); + if (unconditionalDependencies.length === 0) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must keep an unconditional normal bitfun-acp dependency`, + }); + } + if (declaredDependencies.some((dependency) => dependency.optional === true)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must not make a bitfun-acp dependency optional`, + }); + } + if (declaredDependencies.some((dependency) => dependency.uses_default_features !== false)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must set default-features = false on every dependency`, + }); + } + const unconditionalFeatures = new Set( + unconditionalDependencies.flatMap((dependency) => dependency.features ?? []), + ); + const selectedFeatures = new Set( + declaredDependencies.flatMap((dependency) => dependency.features ?? []), + ); + if (unconditionalDependencies.length > 0) { + for (const requiredFeature of rule.requiredFeatures) { + if (!unconditionalFeatures.has(requiredFeature)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must include ${requiredFeature}`, + }); + } + } + } + for (const selectedFeature of selectedFeatures) { + if (!rule.requiredFeatures.includes(selectedFeature)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must not include ${selectedFeature}`, + }); + } + } + } + for (const [sourceName, rule] of reviewedAcpRoleSelections) { + const sourcePackage = packages.find((pkg) => pkg.name === sourceName); + if (sourcePackage && !reviewedConsumersFound.has(sourceName)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must keep the bitfun-acp dependency`, + }); + } + } + } + for (const sourcePackage of packages) { const sourceLayer = layerForManifest(sourcePackage.manifest_path, { root, @@ -904,21 +1146,32 @@ export function findProductEntrypointCoreFeatureViolations( if (targetPackage?.name !== 'bitfun-core') { continue; } - if (dependency.uses_default_features !== false) { - violations.push({ - path: sourcePackage.manifest_path, - line: 1, - message: `product entrypoint ${sourcePackage.name} must set default-features = false for its bitfun-core ${dependencyDescription(dependency)}`, - }); - } - if (!Array.isArray(dependency.features) || dependency.features.length === 0) { + const roleOwnedAcpDependency = + sourcePackage.name === 'bitfun-acp' && dependency.optional === true; + if ( + !roleOwnedAcpDependency + && (!Array.isArray(dependency.features) || dependency.features.length === 0) + ) { violations.push({ path: sourcePackage.manifest_path, line: 1, message: `product entrypoint ${sourcePackage.name} must select at least one explicit feature for its bitfun-core ${dependencyDescription(dependency)}`, }); } - const reviewedClosure = reviewedCoreFeatureClosures.get(sourcePackage.name); + if (fullProductCoreEntrypoints.has(sourcePackage.name)) { + fullProductCoreEntrypointsFound.add(sourcePackage.name); + const selectedFeatures = [...new Set(dependency.features ?? [])].sort(); + if (selectedFeatures.length !== 1 || selectedFeatures[0] !== 'product-full') { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${sourcePackage.name} Core capability closure must select exactly product-full`, + }); + } + } + const reviewedClosure = roleOwnedAcpDependency + ? undefined + : reviewedCoreFeatureClosures.get(sourcePackage.name); if (reviewedClosure) { const selectedFeatures = new Set(dependency.features ?? []); for (const requiredFeature of reviewedClosure) { @@ -942,6 +1195,18 @@ export function findProductEntrypointCoreFeatureViolations( } } } + for (const sourceName of packages.some((pkg) => pkg.name === 'bitfun-core') + ? fullProductCoreEntrypoints + : []) { + const sourcePackage = packages.find((pkg) => pkg.name === sourceName); + if (sourcePackage && !fullProductCoreEntrypointsFound.has(sourceName)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${sourceName} Core capability closure must keep the bitfun-core dependency`, + }); + } + } const corePackage = packages.find((pkg) => pkg.name === 'bitfun-core'); if (corePackage) { @@ -959,7 +1224,7 @@ export function findProductEntrypointCoreFeatureViolations( continue; } const allowedCoreFeatures = new Set( - reviewedActiveCoreFeatureClosures.get(rootName) ?? [], + ['default', ...(reviewedActiveCoreFeatureClosures.get(rootName) ?? [])], ); const rootSelectedFeatures = Object.keys(rootPackage.features ?? {}) .filter((feature) => feature !== 'default'); @@ -967,7 +1232,10 @@ export function findProductEntrypointCoreFeatureViolations( ['bitfun-cli', 'CLI'], ['bitfun-acp', 'ACP'], ['bitfun-app-server', 'App Server'], + ['bitfun-sdk-host-app', 'SDK Host'], ]).get(rootName) ?? rootName; + const forbiddenOwnerFeatures = + reviewedForbiddenDependencyOwnerFeatures.get(rootName); const packageStates = new Map(); const pending = []; @@ -1106,6 +1374,30 @@ export function findProductEntrypointCoreFeatureViolations( } continue; } + + const forbiddenOwnerFeature = ( + forbiddenOwnerFeatures?.get(targetPackage.name) ?? [] + ).find((feature) => targetState.featureState.active.has(feature)); + if (forbiddenOwnerFeature) { + const forbiddenOwner = `${targetPackage.name}/${forbiddenOwnerFeature}`; + const reportKey = [ + rootName, + targetDependencyKindContext, + forbiddenOwner, + ].join('|'); + if (!reportedUnexpectedFeatures.has(reportKey)) { + reportedUnexpectedFeatures.add(reportKey); + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rootLabel} dependency closure must not enable ${forbiddenOwner}: ${[ + ...packagePath, + forbiddenOwner, + ].join(' -> ')}`, + }); + } + continue; + } } } } @@ -1114,6 +1406,363 @@ export function findProductEntrypointCoreFeatureViolations( return violations; } +function normalizedDependencyKind(dependency) { + return dependency.kind ?? 'normal'; +} + +function normalizedDependencyTarget(dependency) { + return dependency.target ?? null; +} + +function sameStringSet(actual, expected) { + const left = [...new Set(actual ?? [])].sort(); + const right = [...new Set(expected ?? [])].sort(); + return left.length === right.length + && left.every((value, index) => value === right[index]); +} + +function featureForwardingReferences(featureGraph, dependency) { + const alias = dependencyAlias(dependency); + const references = []; + for (const [sourceFeature, values] of Object.entries(featureGraph ?? {})) { + for (const value of values) { + const match = value.match(/^([^/?]+)(\?)?\/(.+)$/); + if (match?.[1] === alias) { + references.push({ + sourceFeature, + feature: match[3], + weak: Boolean(match[2]), + }); + } + } + } + return references; +} + +function featureDependencyActivations(featureGraph, dependency) { + const alias = dependencyAlias(dependency); + const activations = []; + for (const [sourceFeature, values] of Object.entries(featureGraph ?? {})) { + if (values.includes(`dep:${alias}`) || values.includes(alias)) { + activations.push(sourceFeature); + } + } + return activations; +} + +function packageMatchesManifest(pkg, manifestPath, root) { + return repositoryPath(root, pkg.manifest_path) === manifestPath; +} + +function dependencyTargetsPackage(dependency, targetPackage) { + return dependency.path !== null + && dependency.path !== undefined + && normalizedPath(join(dependency.path, 'Cargo.toml')) + === normalizedPath(targetPackage.manifest_path); +} + +function dependencyEdgeMatches(dependency, expected) { + return normalizedDependencyKind(dependency) === expected.kind + && dependency.optional === expected.optional + && normalizedDependencyTarget(dependency) === expected.target + && (dependency.rename ?? null) === (expected.rename ?? null) + && sameStringSet(dependency.features, expected.features); +} + +function featureTransitivelyReaches(featureGraph, sourceFeature, destinations, seen = new Set()) { + if (destinations.has(sourceFeature)) { + return true; + } + if (seen.has(sourceFeature)) { + return false; + } + seen.add(sourceFeature); + return (featureGraph[sourceFeature] ?? []).some((reference) => + Object.hasOwn(featureGraph, reference) + && featureTransitivelyReaches(featureGraph, reference, destinations, seen)); +} + +export function findRedundantInternalDefaultFeatureDisables( + packages, + { + root, + guardedManifests = guardedEmptyInternalDefaultManifestPaths, + } = {}, +) { + if (!root) { + throw new Error('redundant internal default-feature check requires the repository root'); + } + + const packageByManifest = new Map( + packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), + ); + const guardedTargets = new Set( + guardedManifests.map((path) => normalizedPath(join(root, path))), + ); + const violations = []; + + for (const consumer of packages) { + for (const dependency of consumer.dependencies ?? []) { + if (dependency.uses_default_features !== false || !dependency.path) { + continue; + } + const targetPackage = packageByManifest.get( + normalizedPath(join(dependency.path, 'Cargo.toml')), + ); + const targetFeatures = targetPackage?.features ?? {}; + if ( + !targetPackage + || !guardedTargets.has(normalizedPath(targetPackage.manifest_path)) + || !Object.hasOwn(targetFeatures, 'default') + || !sameStringSet(targetFeatures.default, []) + ) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} ${targetPackage.name} dependency has redundant default-features = false because the target default is guarded empty`, + }); + } + } + + return violations; +} + +export function findGuardedInternalDefaultFeatureViolations( + packages, + { + root, + guardedManifests = guardedEmptyInternalDefaultManifestPaths, + } = {}, +) { + if (!root) { + throw new Error('guarded internal default-feature check requires the repository root'); + } + + const packageByManifest = new Map( + packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), + ); + const violations = []; + + for (const manifestPath of guardedManifests) { + const targetPackage = packageByManifest.get(normalizedPath(join(root, manifestPath))); + if (!targetPackage) { + violations.push({ + path: join(root, manifestPath), + line: 1, + message: `guarded internal empty-default target is missing: ${manifestPath}`, + }); + continue; + } + const targetFeatures = targetPackage.features ?? {}; + if ( + !Object.hasOwn(targetFeatures, 'default') + || !sameStringSet(targetFeatures.default, []) + ) { + violations.push({ + path: targetPackage.manifest_path, + line: 1, + message: `${targetPackage.name} guarded default feature must stay explicitly empty`, + }); + } + } + + return violations; +} + +export function findCapabilityContractConsumerViolations( + packages, + rules = capabilityContractDependencyRules, + { root } = {}, +) { + const violations = []; + const targetPackages = new Map(); + + if (!root) { + throw new Error('capability contract consumer check requires the repository root'); + } + + for (const rule of rules) { + const targetPackage = packages.find((pkg) => + pkg.name === rule.packageName && packageMatchesManifest(pkg, rule.manifestPath, root)); + if (!targetPackage) { + violations.push({ + path: rule.manifestPath, + line: 1, + message: `${rule.packageName} managed target is missing from Cargo metadata`, + }); + continue; + } + targetPackages.set(rule.packageName, targetPackage); + if ( + !Object.hasOwn(targetPackage.features ?? {}, 'default') + || !sameStringSet(targetPackage.features.default, []) + ) { + violations.push({ + path: targetPackage.manifest_path, + line: 1, + message: `${rule.packageName} capability contract default feature must stay empty`, + }); + } + const actualFeatures = Object.keys(targetPackage.features ?? {}) + .filter((feature) => feature !== 'default'); + const expectedFeatures = Object.keys(rule.featureProfiles) + .filter((feature) => feature !== 'default'); + if (!sameStringSet(actualFeatures, expectedFeatures)) { + violations.push({ + path: targetPackage.manifest_path, + line: 1, + message: `${rule.packageName} capability contract feature surface must stay exact`, + }); + } + for (const [feature, expectedReferences] of Object.entries(rule.featureProfiles)) { + if ( + Object.hasOwn(targetPackage.features ?? {}, feature) + && !sameStringSet(targetPackage.features[feature], expectedReferences) + ) { + violations.push({ + path: targetPackage.manifest_path, + line: 1, + message: `${rule.packageName}:${feature} feature graph must stay exact`, + }); + } + } + } + + for (const rule of rules) { + const targetPackage = targetPackages.get(rule.packageName); + if (!targetPackage) { + continue; + } + for (const consumer of packages) { + const namedDependencies = (consumer.dependencies ?? []).filter( + (dependency) => dependency.name === rule.packageName, + ); + const managedDependencies = namedDependencies.filter((dependency) => + dependencyTargetsPackage(dependency, targetPackage)); + for (const dependency of namedDependencies) { + if (dependencyTargetsPackage(dependency, targetPackage)) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} ${rule.packageName} dependency must use the managed internal path`, + }); + } + + const consumerProfile = rule.consumers.get(consumer.name); + if (!consumerProfile) { + if (managedDependencies.length > 0) { + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} has an unreviewed consumer edge to ${rule.packageName}`, + }); + } + continue; + } + const unmatchedExpectedEdges = [...consumerProfile.edges]; + for (const dependency of managedDependencies) { + const matchIndex = unmatchedExpectedEdges.findIndex((edge) => + dependencyEdgeMatches(dependency, edge)); + if (matchIndex === -1) { + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} has an unreviewed ${rule.packageName} dependency edge`, + }); + } else { + unmatchedExpectedEdges.splice(matchIndex, 1); + } + } + for (const edge of unmatchedExpectedEdges) { + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} is missing reviewed ${edge.kind} ${rule.packageName} dependency edge`, + }); + } + + const actualForwarders = managedDependencies.flatMap((dependency) => + featureForwardingReferences(consumer.features, dependency)); + for (const forwarding of actualForwarders) { + const allowed = (consumerProfile.forwarders ?? []).some((expected) => + expected.sourceFeature === forwarding.sourceFeature + && expected.feature === forwarding.feature + && expected.weak === forwarding.weak); + if (allowed) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${forwarding.sourceFeature} has unreviewed ${rule.packageName}/${forwarding.feature} forwarding`, + }); + } + for (const expected of consumerProfile.forwarders ?? []) { + const present = actualForwarders.some((forwarding) => + expected.sourceFeature === forwarding.sourceFeature + && expected.feature === forwarding.feature + && expected.weak === forwarding.weak); + if (present) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${expected.sourceFeature} is missing reviewed ${rule.packageName}/${expected.feature} forwarding`, + }); + } + const actualActivators = managedDependencies.flatMap((dependency) => + featureDependencyActivations(consumer.features, dependency)); + for (const sourceFeature of actualActivators) { + if ((consumerProfile.activators ?? []).includes(sourceFeature)) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${sourceFeature} has unreviewed ${rule.packageName} activation`, + }); + } + for (const sourceFeature of consumerProfile.activators ?? []) { + if (actualActivators.includes(sourceFeature)) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${sourceFeature} is missing reviewed ${rule.packageName} activation`, + }); + } + + const directOwners = new Set([ + ...(consumerProfile.forwarders ?? []).map(({ sourceFeature }) => sourceFeature), + ...(consumerProfile.activators ?? []), + ]); + const allowedAggregates = new Set(consumerProfile.aggregates ?? []); + for (const feature of Object.keys(consumer.features ?? {})) { + if ( + directOwners.has(feature) + || allowedAggregates.has(feature) + || !featureTransitivelyReaches(consumer.features, feature, directOwners) + ) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${feature} is an unreviewed aggregate of ${rule.packageName} capability owners`, + }); + } + } + } + + return violations; +} + function matchingClosingDelimiter( source, openingIndex, @@ -1526,6 +2175,9 @@ export function checkCargoDependencyBoundaries({ root, crateLayoutRules }) { packages, { root, crateLayoutRules }, ), + ...findGuardedInternalDefaultFeatureViolations(packages, { root }), + ...findRedundantInternalDefaultFeatureDisables(packages, { root }), + ...findCapabilityContractConsumerViolations(packages, undefined, { root }), ...findFeatureGatedTestTargetViolations(packages), ...findRuntimeServicesTestSupportFeatureViolations(packages), ...findTokioDependencyFeatureViolations(packages), diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 0742b296e2..d70a510bc9 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -15,10 +15,12 @@ import { } from './rules/crate-layout.mjs'; import { checkTuiLegacyBackendRatchet } from './tui-boundary-ratchet.mjs'; import { + acpClosedFeatureProfileRules, coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, optionalDependencyFeatureOwnerRules, ownerCrateFeatureAssemblyRules, + reviewedOptionalDependencyAggregateFeatures, } from './rules/feature-rules.mjs'; import { facadeOnlyFiles, @@ -32,15 +34,18 @@ import { runManifestParserSelfTest } from './self-test.mjs'; import { featureReferencesDependency, featureReferencesFeature, + featureReferencesOptionalDependencyOwner, unexpectedDependencyOwnerFeatures, unexpectedReachableLocalFeatures, } from './manifest-feature-helpers.mjs'; import { checkCargoDependencyBoundariesSafely } from './cargo-dependency-boundaries.mjs'; +import { checkPeerCommandPolicySync } from './peer-command-policy.mjs'; import { agentRuntimeIntegrationTestTargets, checkAgentRuntimeIntegrationTestTopology, checkCliIntegrationTestTopology, - checkServiceIntegrationTestTopologies, + checkExternalSourceIntegrationTestTopologies, + checkReviewedIntegrationTestTopologies, cliIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './explicit-test-topology.mjs'; @@ -511,11 +516,16 @@ function checkForbiddenManifestDependencyRule(rule) { function checkOptionalDependencyFeatureOwners(crateDir, rule) { const manifestPath = join(crateDir, 'Cargo.toml'); + const repoManifestPath = toRepoPath(manifestPath); const lines = readText(manifestPath).split(/\r?\n/); const deps = parseManifestDependencies(lines); const normalDeps = deps.filter((dep) => dep.kind === 'normal'); const depsByName = new Map(normalDeps.map((dep) => [dep.name, dep])); const features = parseManifestFeatures(lines); + const reviewedAggregateFeatures = new Set([ + ...reviewedOptionalDependencyAggregateFeatures(repoManifestPath), + ...(rule.reviewedAggregateFeatures ?? []), + ]); const declaredOwnerDeps = new Set(rule.dependencies.map((dependency) => dependency.depName)); for (const dependency of rule.dependencies) { @@ -545,7 +555,7 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) { }); continue; } - if (!featureReferencesDependency(feature, dependency.depName)) { + if (!featureReferencesOptionalDependencyOwner(feature, dependency.depName)) { failures.push({ path: manifestPath, line: feature.line, @@ -553,7 +563,11 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) { }); } } - for (const [featureName, feature] of unexpectedDependencyOwnerFeatures(features, dependency)) { + for (const [featureName, feature] of unexpectedDependencyOwnerFeatures( + features, + dependency, + reviewedAggregateFeatures, + )) { failures.push({ path: manifestPath, line: feature.line, @@ -582,19 +596,6 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) { } } -function checkCoreDefaultProductFullFeature() { - const manifestPath = join(crateDirForName('core'), 'Cargo.toml'); - const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); - if (!featureReferencesFeature(features.get('default'), 'product-full')) { - failures.push({ - path: manifestPath, - line: features.get('default')?.line ?? 1, - message: - 'bitfun-core default feature must remain product-full until a separate product matrix review changes it', - }); - } -} - function checkCoreProductFullFeatureAssembly(rule) { const manifestPath = repoPathToFsPath(rule.manifestPath); const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); @@ -874,14 +875,14 @@ function checkForbiddenContent(repoPath, patterns) { } }); } - function checkRequiredContent(repoPath, patterns, reason) { const path = repoPathToFsPath(repoPath); - const text = readText(path); for (const pattern of patterns) { + const patternPath = pattern.path ? repoPathToFsPath(pattern.path) : path; + const text = readText(patternPath); if (!pattern.regex.test(text)) { failures.push({ - path, + path: patternPath, line: 1, message: `${reason}; ${pattern.message}`, }); @@ -1089,6 +1090,7 @@ export function runCoreBoundaryCheck() { manifestDependencyMatches, matchingForbiddenDependency, coreClosedFeatureProfileRules, + acpClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, ownerCrateFeatureAssemblyRules, parseManifestFeatures, @@ -1123,7 +1125,8 @@ export function runCoreBoundaryCheck() { failures.push(...checkCargoDependencyBoundariesSafely({ root: ROOT, crateLayoutRules })); failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT)); failures.push(...checkCliIntegrationTestTopology(ROOT)); - failures.push(...checkServiceIntegrationTestTopologies(ROOT)); + failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkReviewedIntegrationTestTopologies(ROOT)); + failures.push(...checkPeerCommandPolicySync(ROOT)); for (const rule of forbiddenManifestDependencyRules) { checkForbiddenManifestDependencyRule(rule); @@ -1154,11 +1157,13 @@ export function runCoreBoundaryCheck() { checkOptionalDependencyFeatureOwners(crateDir, rule); } - checkCoreDefaultProductFullFeature(); checkCoreProductFullFeatureAssembly(coreProductFullFeatureAssemblyRule); for (const rule of coreClosedFeatureProfileRules) { checkClosedFeatureProfile(rule); } + for (const rule of acpClosedFeatureProfileRules) { + checkClosedFeatureProfile(rule); + } for (const rule of ownerCrateFeatureAssemblyRules) { checkOwnerCrateFeatureAssembly(rule); } @@ -1178,7 +1183,6 @@ export function runCoreBoundaryCheck() { for (const rule of requiredContentRules) { checkRequiredContent(rule.path, rule.patterns, rule.reason); } - for (const rule of publicApiAllowlistRules) { checkPublicApiAllowlist(rule); } diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 9a157af1d4..01196cee6d 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -2,11 +2,25 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, posix, relative } from 'node:path'; export const agentRuntimeIntegrationTestTargets = [ - { name: 'agent_definition_contracts', path: 'tests/agent_definition_contracts.rs' }, - { name: 'agent_interaction_contracts', path: 'tests/agent_interaction_contracts.rs' }, - { name: 'agent_long_horizon_contracts', path: 'tests/agent_long_horizon_contracts.rs' }, - { name: 'agent_session_contracts', path: 'tests/agent_session_contracts.rs' }, - { name: 'native_hook_execution_contracts', path: 'tests/native_hook_execution_contracts.rs' }, + { name: 'agent_definition_contracts', path: 'tests/agent_definition_contracts.rs', requiredFeatures: ['agent-runtime'] }, + { name: 'agent_interaction_contracts', path: 'tests/agent_interaction_contracts.rs', requiredFeatures: ['agent-runtime'] }, + { name: 'agent_long_horizon_contracts', path: 'tests/agent_long_horizon_contracts.rs', requiredFeatures: ['agent-runtime'] }, + { name: 'agent_session_contracts', path: 'tests/agent_session_contracts.rs', requiredFeatures: ['agent-runtime'] }, + { + name: 'deep_research_contracts', + path: 'tests/deep_research_contracts.rs', + requiredFeatures: ['deep-research'], + }, + { + name: 'native_hook_execution_contracts', + path: 'tests/native_hook_execution_contracts.rs', + requiredFeatures: ['native-hook-runtime'], + }, + { + name: 'native_hook_settings_contracts', + path: 'tests/native_hook_settings_contracts.rs', + requiredFeatures: ['native-hook-settings'], + }, ]; export const cliIntegrationTestTargets = [ @@ -46,6 +60,260 @@ export const servicesIntegrationsIntegrationTestTargets = [ { name: 'workspace_search_contracts', path: 'tests/workspace_search_contracts.rs' }, ]; +export const opencodeAdapterIntegrationTestTargets = [ + { name: 'opencode_mcp_adapter', path: 'tests/opencode_mcp_adapter.rs' }, + { name: 'opencode_source_adapter', path: 'tests/opencode_source_adapter.rs' }, + { + name: 'opencode_static_source_contracts', + path: 'tests/opencode_static_source_contracts.rs', + leaves: [ + 'tests/opencode_static_source_contracts/hook_source.rs', + 'tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_workspace_references.rs', + ], + forbidRequiredFeatures: true, + }, + { name: 'tool_source_contracts', path: 'tests/tool_source_contracts.rs' }, +]; + +export const claudeCodeAdapterIntegrationTestTargets = [ + { + name: 'claude_code_source_contracts', + path: 'tests/claude_code_source_contracts.rs', + leaves: [ + 'tests/claude_code_source_contracts/command_source.rs', + 'tests/claude_code_source_contracts/hook_source.rs', + 'tests/claude_code_source_contracts/mcp_source.rs', + 'tests/claude_code_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const codexAdapterIntegrationTestTargets = [ + { + name: 'codex_source_contracts', + path: 'tests/codex_source_contracts.rs', + leaves: [ + 'tests/codex_source_contracts/hook_source.rs', + 'tests/codex_source_contracts/mcp_source.rs', + 'tests/codex_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const externalSourcesIntegrationTestTargets = [ + { + name: 'external_source_coordination_contracts', + path: 'tests/external_source_coordination_contracts.rs', + leaves: [ + 'tests/external_source_coordination_contracts/control_plane.rs', + 'tests/external_source_coordination_contracts/coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/hook_coordinator.rs', + 'tests/external_source_coordination_contracts/mcp_coordinator.rs', + 'tests/external_source_coordination_contracts/subagent_coordinator.rs', + 'tests/external_source_coordination_contracts/tool_coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/workspace_reference.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const coreTypesIntegrationTestTargets = [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const runtimePortsIntegrationTestTargets = [ + { + name: 'plugin_runtime_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + ], + requiredFeatures: ['plugin-runtime'], + }, + { + name: 'git_port_contracts', + path: 'tests/git_port_contracts.rs', + requiredFeatures: ['git-port'], + }, + { + name: 'script_tool_port_contracts', + path: 'tests/script_tool_port_contracts.rs', + requiredFeatures: ['script-tool-runtime'], + }, + { + name: 'session_store_contracts', + path: 'tests/session_store_contracts.rs', + requiredFeatures: ['workspace-ports'], + }, +]; + +export const productDomainsIntegrationTestTargets = [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, +]; + +export const aiAdaptersIntegrationTestTargets = [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const productCapabilitiesIntegrationTestTargets = [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +function decodeBasicTomlKey(token) { + let decoded = ''; + const simpleEscapes = new Map([ + ['b', '\b'], ['t', '\t'], ['n', '\n'], ['f', '\f'], ['r', '\r'], + ['"', '"'], ['\\', '\\'], + ]); + for (let index = 1; index < token.length - 1; index += 1) { + if (token[index] !== '\\') { + decoded += token[index]; + continue; + } + index += 1; + const escape = token[index]; + if (simpleEscapes.has(escape)) { + decoded += simpleEscapes.get(escape); + continue; + } + if (escape !== 'u' && escape !== 'U') { + return null; + } + const digitCount = escape === 'u' ? 4 : 8; + const hex = token.slice(index + 1, index + 1 + digitCount); + if (!new RegExp(`^[0-9a-fA-F]{${digitCount}}$`).test(hex)) { + return null; + } + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + return null; + } + decoded += String.fromCodePoint(codePoint); + index += digitCount; + } + return decoded; +} + +function tomlFieldName(line) { + const match = line.match(/^([A-Za-z0-9_-]+|'[^']*'|"(?:[^"\\]|\\.)*")\s*=/); + if (!match) { + return null; + } + const token = match[1]; + if (token.startsWith("'")) { + return token.slice(1, -1); + } + return token.startsWith('"') ? decodeBasicTomlKey(token) : token; +} + +function parseTomlStringArrayValue(line) { + const equalsIndex = line.indexOf('='); + const value = equalsIndex === -1 ? '' : line.slice(equalsIndex + 1).trim(); + const array = value.match(/^\[(.*)\]\s*(?:#.*)?$/); + if (!array) { + return null; + } + const inner = array[1]; + const values = []; + const stringPattern = /'[^']*'|"(?:[^"\\]|\\.)*"/g; + let cursor = 0; + for (const match of inner.matchAll(stringPattern)) { + if (!/^[\s,]*$/.test(inner.slice(cursor, match.index))) { + return null; + } + const token = match[0]; + const decoded = token.startsWith("'") + ? token.slice(1, -1) + : decodeBasicTomlKey(token); + if (decoded === null) { + return null; + } + values.push(decoded); + cursor = match.index + token.length; + } + return /^[\s,]*$/.test(inner.slice(cursor)) ? values : null; +} + function parseExplicitTestTargets(manifestText) { const targets = []; let current = null; @@ -67,6 +335,10 @@ function parseExplicitTestTargets(manifestText) { finishCurrent(); continue; } + if (current && tomlFieldName(trimmed) === 'required-features') { + current.hasRequiredFeatures = true; + current.requiredFeatures = parseTomlStringArrayValue(trimmed); + } const field = current && trimmed.match(/^(name|path)\s*=\s*"([^"]+)"\s*$/); if (field) { current[field[1]] = field[2]; @@ -386,12 +658,41 @@ export function validateExplicitIntegrationTestTopology({ } const expectedTargetEntries = expectedTargets.map(({ name, path }) => `${name}=${path}`).sort(); - const actualTargetEntries = parseExplicitTestTargets(manifestText) + const actualTargets = parseExplicitTestTargets(manifestText); + const actualTargetEntries = actualTargets .map(({ name, path }) => `${name ?? ''}=${path ?? ''}`) .sort(); if (actualTargetEntries.join('\n') !== expectedTargetEntries.join('\n')) { errors.push(`explicit test targets must be exactly: ${expectedTargetEntries.join(', ')}`); } + const targetsWithoutRequiredFeatures = new Set( + expectedTargets + .filter(({ forbidRequiredFeatures }) => forbidRequiredFeatures) + .map(({ name, path }) => `${name}=${path}`), + ); + for (const { name, path, hasRequiredFeatures } of actualTargets) { + if (hasRequiredFeatures && targetsWithoutRequiredFeatures.has(`${name}=${path}`)) { + errors.push(`explicit test target ${name} must not declare required-features`); + } + } + for (const { name, path, requiredFeatures } of expectedTargets) { + if (requiredFeatures === undefined) { + continue; + } + const actual = actualTargets.find( + (target) => target.name === name && target.path === path, + ); + const actualRequiredFeatures = actual?.requiredFeatures; + if ( + actualRequiredFeatures === null + || actualRequiredFeatures === undefined + || [...actualRequiredFeatures].sort().join('\n') !== [...requiredFeatures].sort().join('\n') + ) { + errors.push( + `explicit test target ${name} required-features must be exactly: ${requiredFeatures.join(', ')}`, + ); + } + } const expectedRoots = expectedTargets.map(({ path }) => path).sort(); if ([...topLevelRustFiles].sort().join('\n') !== expectedRoots.join('\n')) { @@ -399,6 +700,13 @@ export function validateExplicitIntegrationTestTopology({ } const leaves = new Set(leafRustFiles); + const expectedLeaves = expectedTargets.flatMap(({ leaves: targetLeaves = [] }) => targetLeaves).sort(); + if ( + expectedLeaves.length > 0 + && [...leaves].sort().join('\n') !== expectedLeaves.join('\n') + ) { + errors.push(`grouped test leaves must be exactly: ${expectedLeaves.join(', ')}`); + } const referenceCounts = new Map(); for (const root of expectedRoots) { const source = rootSources.get(root); @@ -543,9 +851,75 @@ export function checkServicesIntegrationsIntegrationTestTopology(root) { }); } +export function checkOpencodeAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/opencode-adapter', + expectedTargets: opencodeAdapterIntegrationTestTargets, + ignoredDirectories: ['tests/fixtures'], + }); +} + +export function checkClaudeCodeAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/claude-code-adapter', + expectedTargets: claudeCodeAdapterIntegrationTestTargets, + }); +} + +export function checkCodexAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/codex-adapter', + expectedTargets: codexAdapterIntegrationTestTargets, + }); +} + +export function checkExternalSourcesIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/assembly/external-sources', + expectedTargets: externalSourcesIntegrationTestTargets, + }); +} + +export function checkExternalSourceIntegrationTestTopologies(root) { + return [ + ...checkOpencodeAdapterIntegrationTestTopology(root), + ...checkClaudeCodeAdapterIntegrationTestTopology(root), + ...checkCodexAdapterIntegrationTestTopology(root), + ...checkExternalSourcesIntegrationTestTopology(root), + ]; +} + export function checkServiceIntegrationTestTopologies(root) { return [ ...checkServicesCoreIntegrationTestTopology(root), ...checkServicesIntegrationsIntegrationTestTopology(root), ]; } + +export function checkBuildGraphContractIntegrationTestTopologies(root) { + const topologies = [ + ['src/crates/contracts/core-types', coreTypesIntegrationTestTargets], + ['src/crates/contracts/runtime-ports', runtimePortsIntegrationTestTargets], + ['src/crates/contracts/product-domains', productDomainsIntegrationTestTargets], + [ + 'src/crates/adapters/ai-adapters', + aiAdaptersIntegrationTestTargets, + ['tests/common', 'tests/fixtures'], + ], + ['src/crates/assembly/product-capabilities', productCapabilitiesIntegrationTestTargets], + ]; + return topologies.flatMap(([cratePath, expectedTargets, ignoredDirectories]) => ( + checkExplicitIntegrationTestTopology(root, { + cratePath, + expectedTargets, + ignoredDirectories, + }) + )); +} + +export function checkReviewedIntegrationTestTopologies(root) { + return [ + ...checkServiceIntegrationTestTopologies(root), + ...checkBuildGraphContractIntegrationTestTopologies(root), + ]; +} diff --git a/scripts/core-boundaries/manifest-feature-helpers.mjs b/scripts/core-boundaries/manifest-feature-helpers.mjs index 4445e20707..618f04ca59 100644 --- a/scripts/core-boundaries/manifest-feature-helpers.mjs +++ b/scripts/core-boundaries/manifest-feature-helpers.mjs @@ -10,15 +10,64 @@ export function featureReferencesDependency(feature, depName) { ); } +export function featureReferencesOptionalDependencyOwner(feature, depName) { + return Boolean( + featureReferencesDependency(feature, depName) + || feature?.refs.some((reference) => reference.startsWith(`${depName}?/`)), + ); +} + export function featureReferencesFeature(feature, featureName) { return Boolean(feature && feature.refs.includes(featureName)); } -export function unexpectedDependencyOwnerFeatures(features, dependency) { +function featureTransitivelyReferencesOptionalDependencyOwner( + features, + featureName, + depName, + visiting = new Set(), +) { + if (visiting.has(featureName)) { + return false; + } + const feature = features.get(featureName); + if (featureReferencesOptionalDependencyOwner(feature, depName)) { + return true; + } + const nextVisiting = new Set(visiting).add(featureName); + return Boolean(feature?.refs.some((reference) => + features.has(reference) + && featureTransitivelyReferencesOptionalDependencyOwner( + features, + reference, + depName, + nextVisiting, + ))); +} + +export function unexpectedDependencyOwnerFeatures( + features, + dependency, + reviewedAggregateFeatures = new Set(), +) { return [...features.entries()].filter( - ([featureName, feature]) => - featureReferencesDependency(feature, dependency.depName) - && !dependency.ownerFeatures.includes(featureName), + ([featureName, feature]) => { + if (dependency.ownerFeatures.includes(featureName)) { + return false; + } + const directlyReferencesOwner = featureReferencesOptionalDependencyOwner( + feature, + dependency.depName, + ); + if (reviewedAggregateFeatures.has(featureName)) { + return directlyReferencesOwner; + } + return featureTransitivelyReferencesOptionalDependencyOwner( + features, + featureName, + dependency.depName, + ); + }, ); } diff --git a/scripts/core-boundaries/peer-command-policy.mjs b/scripts/core-boundaries/peer-command-policy.mjs new file mode 100644 index 0000000000..7be725188d --- /dev/null +++ b/scripts/core-boundaries/peer-command-policy.mjs @@ -0,0 +1,137 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * Peer Device Mode controller/peer command ownership boundary. + * + * The controller-side deny list in the Web UI transport adapter is an + * optimization: it keeps a controller-owned command on the controller without + * a round trip. It is not the boundary. A controller running an older build, + * or any non-Web-UI controller, still reaches a peer host over HostInvoke, so + * each peer host must independently refuse every controller-owned command. + * + * The enforced direction is therefore one-way: whatever the controller refuses + * to send, a peer host must also refuse to run. A host denying more than the + * controller is safe and stays allowed. + */ + +const FE_ADAPTER = 'src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts'; +const DESKTOP_HOST = 'src/apps/desktop/src/api/peer_host_invoke.rs'; +const CLI_HOST = 'src/apps/cli/src/peer_host/deny.rs'; + +/** + * Commands the CLI peer host answers before the deny-list check, so they are + * intentionally absent from its list. See `src/apps/cli/src/peer_host/dispatch.rs`. + */ +const CLI_PRE_HANDLED_COMMANDS = new Set([ + 'peer_control_attach', + 'peer_control_detach', + 'peer_mode_ping', + 'account_cancel_pending_login', +]); + +function stripLineComments(text) { + return text.replace(/\/\/[^\n]*/g, ''); +} + +function parseTypeScriptSet(source, name) { + const match = new RegExp(`const ${name}\\s*=\\s*new Set\\(\\[(.*?)\\n\\]\\);`, 's').exec(source); + if (!match) { + return null; + } + return new Set(Array.from(stripLineComments(match[1]).matchAll(/'([^']+)'/g), m => m[1])); +} + +function parseRustSlice(source, name) { + const match = new RegExp(`static ${name}[^=]*=\\s*&\\[(.*?)\\n\\];`, 's').exec(source); + if (!match) { + return null; + } + return new Set(Array.from(stripLineComments(match[1]).matchAll(/"([^"]+)"/g), m => m[1])); +} + +export function checkPeerCommandPolicySync(root) { + const failures = []; + + const read = (relativePath) => { + try { + return readFileSync(join(root, relativePath), 'utf8'); + } catch { + failures.push({ + path: relativePath, + line: 1, + message: + 'Peer command policy check could not read this file; update scripts/core-boundaries/peer-command-policy.mjs if it moved', + }); + return null; + } + }; + + const feSource = read(FE_ADAPTER); + const desktopSource = read(DESKTOP_HOST); + const cliSource = read(CLI_HOST); + if (!feSource || !desktopSource || !cliSource) { + return failures; + } + + const controllerDenied = parseTypeScriptSet(feSource, 'LOCAL_ONLY_COMMANDS'); + const desktopDenied = parseRustSlice(desktopSource, 'LOCAL_ONLY_COMMANDS'); + const cliDenied = parseRustSlice(cliSource, 'LOCAL_ONLY_COMMANDS'); + + for (const [path, parsed] of [ + [FE_ADAPTER, controllerDenied], + [DESKTOP_HOST, desktopDenied], + [CLI_HOST, cliDenied], + ]) { + if (!parsed) { + failures.push({ + path, + line: 1, + message: + 'Could not parse LOCAL_ONLY_COMMANDS; keep the declaration shape the peer command policy check expects', + }); + } + } + if (!controllerDenied || !desktopDenied || !cliDenied) { + return failures; + } + + const missingOnDesktop = [...controllerDenied].filter(command => !desktopDenied.has(command)); + if (missingOnDesktop.length > 0) { + failures.push({ + path: DESKTOP_HOST, + line: 1, + message: + `Desktop peer host must refuse every controller-owned command. Missing from LOCAL_ONLY_COMMANDS: ${missingOnDesktop.sort().join(', ')}. ` + + 'An older or non-Web-UI controller can still HostInvoke these onto this peer', + }); + } + + const missingOnCli = [...controllerDenied].filter( + command => !cliDenied.has(command) && !CLI_PRE_HANDLED_COMMANDS.has(command), + ); + if (missingOnCli.length > 0) { + failures.push({ + path: CLI_HOST, + line: 1, + message: + `CLI peer host must refuse every controller-owned command. Missing from LOCAL_ONLY_COMMANDS: ${missingOnCli.sort().join(', ')}. ` + + 'An older or non-Web-UI controller can still HostInvoke these onto this peer', + }); + } + + const staleCliExceptions = [...CLI_PRE_HANDLED_COMMANDS].filter( + command => !controllerDenied.has(command), + ); + if (staleCliExceptions.length > 0) { + failures.push({ + path: CLI_HOST, + line: 1, + message: + `Stale CLI pre-handled exception(s) in scripts/core-boundaries/peer-command-policy.mjs: ${staleCliExceptions.sort().join(', ')}. ` + + 'Remove the exception once the controller no longer treats the command as controller-owned', + }); + } + + return failures; +} diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 47d3c80247..54d9d872a2 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -39,6 +39,7 @@ export const crateLayoutRules = [ { crateName: 'claude-code-adapter', layer: 'adapters', path: 'src/crates/adapters/claude-code-adapter' }, { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, { crateName: 'opencode-adapter', layer: 'adapters', path: 'src/crates/adapters/opencode-adapter' }, + { crateName: 'dsh-adapter', layer: 'adapters', path: 'src/crates/adapters/dsh-adapter' }, { crateName: 'static-hook-support', layer: 'adapters', path: 'src/crates/adapters/static-hook-support' }, { crateName: 'transport', layer: 'adapters', path: 'src/crates/adapters/transport' }, { crateName: 'webdriver', layer: 'adapters', path: 'src/crates/adapters/webdriver' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index ab5288042e..b46e630bc9 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -10,6 +10,7 @@ const agentRuntimeIpcForbiddenDeps = [ 'bitfun-codex-adapter', 'bitfun-core', 'bitfun-core-types', + 'bitfun-dsh-adapter', 'bitfun-external-sources', 'bitfun-harness', 'bitfun-opencode-adapter', @@ -24,7 +25,6 @@ const agentRuntimeIpcForbiddenDeps = [ 'bitfun-static-hook-support', 'bitfun-tool-call-jsonrepair', 'bitfun-tool-packs', - 'bitfun-transport', 'bitfun-webdriver', 'terminal-core', 'tool-runtime', @@ -62,6 +62,7 @@ export const noCoreDependencyCrates = [ 'product-domains', 'claude-code-adapter', 'codex-adapter', + 'dsh-adapter', 'opencode-adapter', 'static-hook-support', 'external-sources', @@ -117,6 +118,19 @@ export const forbiddenManifestDependencyRules = [ message: 'only bitfun-core product-full assembly may register bitfun-opencode-adapter through reviewed capability composition roots', }, + { + dependencyNames: ['bitfun-dsh-adapter'], + scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], + workspaceManifestPath: 'Cargo.toml', + allowManifestPaths: [ + 'src/crates/adapters/dsh-adapter/Cargo.toml', + 'src/crates/assembly/core/Cargo.toml', + ], + reason: + 'DeepSeek Harness adapter production dependencies are limited to the reviewed product composition root', + message: + 'only bitfun-core product-full assembly may register bitfun-dsh-adapter through reviewed capability composition roots', + }, ...[ ['bitfun-claude-code-adapter', 'claude-code-adapter'], ['bitfun-codex-adapter', 'codex-adapter'], @@ -462,7 +476,7 @@ export const dependencyProfileRules = [ crateName: 'agent-runtime-ipc', profileName: 'private Shared TUI local IPC profile', reason: - 'agent-runtime-ipc may share stable event and Runtime DTO contracts but must not acquire Runtime owners, SDK Host, services, remote transports, or product implementations', + 'agent-runtime-ipc may share stable DTO contracts and protocol-neutral bitfun-transport mechanics but must not acquire Runtime owners, SDK Host, services, remote transports, or product implementations', forbiddenNonOptionalDeps: agentRuntimeIpcForbiddenDeps, }, { @@ -476,6 +490,7 @@ export const dependencyProfileRules = [ 'bitfun-services-integrations', 'bitfun-product-capabilities', 'bitfun-product-domains', + 'bitfun-agent-tools', 'bitfun-relay-service', 'bitfun-tool-packs', 'chrono-tz', @@ -484,6 +499,7 @@ export const dependencyProfileRules = [ 'eventsource-stream', 'filetime', 'flate2', + 'fluent-bundle', 'fs2', 'git2', 'glob', @@ -513,6 +529,8 @@ export const dependencyProfileRules = [ 'terminal-core', 'tool-runtime', 'tokio-tungstenite', + 'bitfun-transport', + 'unic-langid', 'win32job', 'x25519-dalek', ], @@ -537,9 +555,11 @@ export const dependencyProfileRules = [ 'ignore', 'libc', 'notify', + 'regex', 'rusqlite', 'serde_yaml', 'sha2', + 'similar', 'which', 'win32job', 'windows', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index a33661360f..10cc1fb43e 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -16,6 +16,19 @@ export const servicesReqwestOwnerFeatures = [ 'web-tools', ]; +export const guardedEmptyInternalDefaultManifestPaths = [ + 'src/crates/assembly/core/Cargo.toml', + 'src/crates/assembly/product-capabilities/Cargo.toml', + 'src/crates/contracts/product-domains/Cargo.toml', + 'src/crates/contracts/runtime-ports/Cargo.toml', + 'src/crates/execution/agent-runtime/Cargo.toml', + 'src/crates/execution/tool-contracts/Cargo.toml', + 'src/crates/execution/tool-execution/Cargo.toml', + 'src/crates/execution/tool-provider-groups/Cargo.toml', + 'src/crates/services/services-core/Cargo.toml', + 'src/crates/services/services-integrations/Cargo.toml', +]; + export const optionalDependencyFeatureOwnerRules = [ { crateName: 'services-core', @@ -36,8 +49,19 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'ignore', ownerFeatures: ['filesystem'] }, { depName: 'libc', ownerFeatures: ['local-storage', 'process-runtime'] }, { depName: 'notify', ownerFeatures: ['lsp'] }, + { + depName: 'regex', + ownerFeatures: [ + 'diagnostics', + 'filesystem', + 'local-storage', + 'markdown', + 'workspace-instructions', + ], + }, { depName: 'rusqlite', ownerFeatures: ['permission'] }, { depName: 'serde_yaml', ownerFeatures: ['markdown', 'workspace-instructions'] }, + { depName: 'similar', ownerFeatures: ['diff', 'local-storage'] }, { depName: 'sha2', ownerFeatures: [ @@ -52,14 +76,63 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'win32job', ownerFeatures: ['process-runtime'] }, { depName: 'windows', ownerFeatures: ['json-io', 'local-storage', 'process-runtime'] }, { depName: 'zip', ownerFeatures: ['lsp'] }, + { + depName: 'tokio', + ownerFeatures: [ + 'diff', + 'filesystem', + 'json-io', + 'local-storage', + 'lsp', + 'permission', + 'process-runtime', + 'workspace-instructions', + 'workspace-runtime', + 'workspace-text-runtime', + ], + }, ], }, { crateName: 'runtime-ports', + reviewedAggregateFeatures: ['tool-runtime-handles'], + reason: + 'runtime-ports optional capabilities must stay behind their exact contract slice', + dependencies: [ + { depName: 'anyhow', ownerFeatures: ['workspace-ports'] }, + { depName: 'bitfun-core-types', ownerFeatures: ['agent-api', 'ts'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['permission', 'ts'] }, + { depName: 'tokio', ownerFeatures: ['remote-exec-port', 'terminal-port'] }, + { depName: 'tokio-util', ownerFeatures: ['workspace-ports'] }, + { depName: 'ts-rs', ownerFeatures: ['ts'] }, + ], + }, + { + crateName: 'agent-runtime', + reviewedAggregateFeatures: ['native-hook-runtime'], reason: - 'runtime-ports may expose product-domain permission ports only through the explicit permission contract slice', + 'agent-runtime optional dependencies must stay behind the full runtime, DeepResearch, or native-hook owner slice', dependencies: [ - { depName: 'bitfun-product-domains', ownerFeatures: ['permission'] }, + { depName: 'async-trait', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-agent-tools', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-core-types', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-events', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-harness', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-runtime-ports', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-runtime-services', ownerFeatures: ['agent-runtime'] }, + { depName: 'dashmap', ownerFeatures: ['agent-runtime'] }, + { depName: 'hex', ownerFeatures: ['agent-runtime'] }, + { depName: 'log', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'regex', ownerFeatures: ['agent-runtime', 'deep-research', 'native-hook-settings'] }, + { depName: 'serde', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'serde_json', ownerFeatures: ['agent-runtime', 'native-hook-runtime', 'native-hook-settings'] }, + { depName: 'serde_yaml', ownerFeatures: ['agent-runtime'] }, + { depName: 'sha2', ownerFeatures: ['agent-runtime'] }, + { depName: 'thiserror', ownerFeatures: ['agent-runtime'] }, + { depName: 'tokio', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'tokio-util', ownerFeatures: ['agent-runtime'] }, + { depName: 'uuid', ownerFeatures: ['agent-runtime'] }, ], }, { @@ -68,14 +141,20 @@ export const optionalDependencyFeatureOwnerRules = [ 'bitfun-core product/runtime optional dependencies must stay owned by explicit feature gates', dependencies: [ { depName: 'axum', ownerFeatures: ['debug-log', 'mcp-runtime'] }, - { depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime'] }, - { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime'] }, + { depName: 'base64', ownerFeatures: ['agent-runtime', 'dispatch-store'] }, + { + depName: 'bitfun-ai-adapters', + ownerFeatures: ['ai-adapter-runtime', 'subscription-auth'], + }, + { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime', 'deep-research'] }, { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-agent-tools', ownerFeatures: ['agent-runtime', 'local-storage', 'mcp-runtime'] }, { depName: 'bitfun-claude-code-adapter', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-codex-adapter', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-external-sources', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-harness', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-opencode-adapter', ownerFeatures: ['external-sources'] }, + { depName: 'bitfun-dsh-adapter', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-plugin-runtime-client', ownerFeatures: ['plugin-runtime'] }, { depName: 'bitfun-product-capabilities', ownerFeatures: ['product-capabilities'] }, { @@ -86,6 +165,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'function-agents', 'plugin-source', 'tools-miniapp', + 'ts', ], }, { depName: 'bitfun-runtime-services', ownerFeatures: ['runtime-services'] }, @@ -110,6 +190,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'script-tool-runtime', 'ssh-remote', 'tools-miniapp', + 'ts', 'web-tools', 'workspace-search', ], @@ -134,12 +215,15 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'dashmap', ownerFeatures: ['agent-runtime'] }, { depName: 'filetime', ownerFeatures: ['agent-runtime'] }, { depName: 'flate2', ownerFeatures: ['agent-runtime'] }, + { depName: 'fluent-bundle', ownerFeatures: ['i18n-runtime'] }, { depName: 'fs2', ownerFeatures: ['agent-runtime'] }, + { depName: 'futures', ownerFeatures: ['agent-runtime'] }, { depName: 'image', ownerFeatures: ['agent-runtime'] }, { depName: 'include_dir', ownerFeatures: ['agent-runtime'] }, { depName: 'indexmap', ownerFeatures: ['agent-runtime'] }, { depName: 'md5', ownerFeatures: ['agent-runtime'] }, - { depName: 'reqwest', ownerFeatures: ['ai-adapter-runtime', 'mcp-runtime', 'tools-miniapp'] }, + { depName: 'reqwest', ownerFeatures: ['mcp-runtime', 'tools-miniapp'] }, + { depName: 'regex', ownerFeatures: ['agent-runtime'] }, { depName: 'rusqlite', ownerFeatures: ['agent-runtime'] }, { depName: 'semver', ownerFeatures: ['tools-miniapp'] }, { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, @@ -147,8 +231,13 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'terminal-core', ownerFeatures: ['terminal'] }, { depName: 'notify', ownerFeatures: ['lsp', 'workspace-watch'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['browser-control'] }, + { depName: 'tokio-util', ownerFeatures: ['agent-runtime', 'debug-log'] }, { depName: 'tower-http', ownerFeatures: ['debug-log'] }, - { depName: 'tool-runtime', ownerFeatures: ['agent-runtime', 'tools-basic', 'web-tools'] }, + { depName: 'unic-langid', ownerFeatures: ['i18n-runtime'] }, + { + depName: 'tool-runtime', + ownerFeatures: ['agent-runtime', 'document-read', 'web-tools'], + }, ], }, { @@ -158,10 +247,10 @@ export const optionalDependencyFeatureOwnerRules = [ dependencies: [ { depName: 'aes', ownerFeatures: ['remote-connect'] }, { depName: 'aes-gcm', ownerFeatures: ['feedback', 'mcp', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'feedback', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, + { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'deep-research', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['feedback', 'git', 'mcp', 'miniapp-market', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], + ownerFeatures: ['feedback', 'deep-research', 'git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], }, { depName: 'base64', @@ -169,8 +258,8 @@ export const optionalDependencyFeatureOwnerRules = [ }, { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research', 'hook-import'] }, { depName: 'bitfun-core-types', ownerFeatures: ['remote-connect', 'speech'] }, - { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'feedback', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source', 'privacy'] }, - { depName: 'bitfun-runtime-ports', ownerFeatures: ['git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'feedback', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, + { depName: 'bitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', ownerFeatures: ['browser-control', 'git', 'hook-import', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh', 'review-platform', 'workspace-search'], @@ -236,11 +325,320 @@ export const optionalDependencyFeatureOwnerRules = [ }, ]; +function capabilityEdge(features = [], overrides = {}) { + return { + kind: 'normal', + optional: false, + rename: null, + target: null, + features, + ...overrides, + }; +} + +function capabilityForwarder(sourceFeature, feature, weak = false) { + return { sourceFeature, feature, weak }; +} + +function capabilityConsumer(edges, forwarders = [], activators = [], aggregates = []) { + return { edges, forwarders, activators, aggregates }; +} + +export const capabilityContractDependencyRules = [ + { + packageName: 'bitfun-runtime-ports', + manifestPath: 'src/crates/contracts/runtime-ports/Cargo.toml', + featureProfiles: { + default: [], + 'agent-api': ['dep:bitfun-core-types'], + 'git-port': [], + permission: ['dep:bitfun-product-domains'], + 'plugin-runtime': [], + 'remote-exec-port': ['dep:tokio'], + 'remote-workspace-ports': [], + 'runtime-event-port': [], + 'script-tool-runtime': [], + 'terminal-port': ['dep:tokio'], + 'tool-runtime-handles': ['workspace-ports', 'terminal-port', 'remote-exec-port'], + ts: [ + 'dep:ts-rs', + 'agent-api', + 'permission', + 'bitfun-core-types/ts', + 'bitfun-product-domains?/ts', + ], + 'workspace-ports': ['dep:anyhow', 'dep:tokio-util'], + }, + consumers: new Map([ + ['bitfun-agent-runtime', capabilityConsumer([ + capabilityEdge([ + 'agent-api', + 'git-port', + 'permission', + 'plugin-runtime', + 'remote-workspace-ports', + 'runtime-event-port', + 'terminal-port', + 'workspace-ports', + ], { optional: true }), + ], [], ['agent-runtime'])], + ['bitfun-agent-runtime-ipc', capabilityConsumer([ + capabilityEdge(['agent-api', 'git-port']), + ])], + ['bitfun-agent-tools', capabilityConsumer([capabilityEdge()])], + ['bitfun-app-server', capabilityConsumer([capabilityEdge(['agent-api'])])], + ['bitfun-app-server-protocol', capabilityConsumer( + [capabilityEdge(['agent-api', 'git-port'])], + [capabilityForwarder('ts', 'ts')], + )], + ['bitfun-cli', capabilityConsumer([ + capabilityEdge(['agent-api', 'git-port', 'permission', 'plugin-runtime', 'workspace-ports']), + ])], + ['bitfun-core', capabilityConsumer( + [capabilityEdge(['permission', 'workspace-ports'])], + [ + capabilityForwarder('agent-runtime', 'agent-api'), + capabilityForwarder('agent-runtime', 'git-port'), + capabilityForwarder('agent-runtime', 'remote-exec-port'), + capabilityForwarder('agent-runtime', 'remote-workspace-ports'), + capabilityForwarder('agent-runtime', 'runtime-event-port'), + capabilityForwarder('agent-runtime', 'terminal-port'), + capabilityForwarder('agent-runtime', 'tool-runtime-handles'), + capabilityForwarder('agent-runtime', 'workspace-ports'), + capabilityForwarder('plugin-runtime', 'plugin-runtime'), + capabilityForwarder('script-tool-runtime', 'script-tool-runtime'), + capabilityForwarder('ts', 'ts'), + ], + [], + ['external-sources', 'mcp-runtime', 'product-full', 'remote-connect', 'tools-mcp'], + )], + ['bitfun-desktop', capabilityConsumer([ + capabilityEdge(['agent-api', 'permission', 'workspace-ports']), + ])], + ['bitfun-opencode-adapter', capabilityConsumer([ + capabilityEdge(['plugin-runtime']), + capabilityEdge(['script-tool-runtime'], { kind: 'dev' }), + ])], + ['bitfun-dsh-adapter', capabilityConsumer([ + capabilityEdge(['plugin-runtime']), + ])], + ['bitfun-plugin-runtime-client', capabilityConsumer([ + capabilityEdge(['plugin-runtime']), + ])], + ['bitfun-product-capabilities', capabilityConsumer([ + capabilityEdge(['plugin-runtime']), + capabilityEdge(['agent-api'], { kind: 'dev' }), + ])], + ['bitfun-runtime-services', capabilityConsumer([ + capabilityEdge([ + 'git-port', + 'remote-exec-port', + 'remote-workspace-ports', + 'runtime-event-port', + 'terminal-port', + 'workspace-ports', + ]), + ])], + ['bitfun-sdk-host', capabilityConsumer([ + capabilityEdge(['agent-api']), + capabilityEdge(['permission'], { kind: 'dev' }), + ])], + ['bitfun-services-core', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('permission', 'permission'), + capabilityForwarder('workspace-runtime', 'runtime-event-port'), + capabilityForwarder('workspace-runtime', 'workspace-ports'), + ], + ['permission', 'workspace-runtime'], + )], + ['bitfun-services-integrations', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('deep-research', 'workspace-ports'), + capabilityForwarder('git', 'git-port'), + capabilityForwarder('remote-connect', 'agent-api'), + capabilityForwarder('remote-connect', 'remote-workspace-ports'), + capabilityForwarder('remote-ssh', 'remote-exec-port'), + capabilityForwarder('remote-ssh', 'remote-workspace-ports'), + capabilityForwarder('remote-ssh', 'workspace-ports'), + capabilityForwarder('script-tool-runtime', 'script-tool-runtime'), + ], + ['remote-ssh-concrete'], + ['function-agents', 'product-full'], + )], + ['terminal-core', capabilityConsumer([ + capabilityEdge(['terminal-port']), + ])], + ['tool-runtime', capabilityConsumer([capabilityEdge()])], + ]), + }, + { + packageName: 'bitfun-agent-tools', + manifestPath: 'src/crates/execution/tool-contracts/Cargo.toml', + featureProfiles: { + default: [], + 'acp-bridge': [], + 'computer-use-contract': [], + 'element-token': [], + 'mcp-bridge': [], + }, + consumers: new Map([ + ['bitfun-acp', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [capabilityForwarder('client', 'acp-bridge')], + ['server'], + ['default'], + )], + ['bitfun-agent-runtime', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [], + ['agent-runtime'], + )], + ['bitfun-agent-stream', capabilityConsumer([ + capabilityEdge([], { kind: 'dev' }), + ])], + ['bitfun-cli', capabilityConsumer([capabilityEdge()])], + ['bitfun-core', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('agent-runtime', 'computer-use-contract'), + capabilityForwarder('mcp-runtime', 'mcp-bridge'), + ], + ['agent-runtime', 'local-storage'], + [ + 'dispatch-store', + 'external-sources', + 'plugin-runtime', + 'product-full', + 'remote-connect', + 'remote-workspace', + 'ssh-remote', + 'tools-basic', + 'tools-mcp', + 'workspace-runtime', + 'workspace-search', + 'workspace-watch', + ], + )], + ['bitfun-desktop', capabilityConsumer([ + capabilityEdge(['element-token']), + ])], + ['bitfun-services-integrations', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [capabilityForwarder('mcp', 'mcp-bridge')], + ['remote-connect'], + ['product-full'], + )], + ['tool-runtime', capabilityConsumer([capabilityEdge()])], + ]), + }, + { + packageName: 'bitfun-agent-runtime', + manifestPath: 'src/crates/execution/agent-runtime/Cargo.toml', + featureProfiles: { + default: [], + 'deep-research': ['dep:regex'], + 'native-hook-settings': ['dep:regex', 'dep:serde_json'], + 'native-hook-runtime': [ + 'native-hook-settings', + 'dep:log', + 'dep:serde', + 'dep:serde_json', + 'dep:tokio', + 'tokio/io-util', + 'tokio/macros', + 'tokio/process', + 'tokio/rt', + 'tokio/time', + ], + 'agent-runtime': [ + 'native-hook-runtime', + 'dep:async-trait', + 'dep:bitfun-agent-stream', + 'dep:bitfun-agent-tools', + 'dep:bitfun-core-types', + 'dep:bitfun-events', + 'dep:bitfun-harness', + 'dep:bitfun-runtime-ports', + 'dep:bitfun-runtime-services', + 'dep:dashmap', + 'dep:hex', + 'dep:log', + 'dep:regex', + 'dep:serde', + 'dep:serde_json', + 'dep:serde_yaml', + 'dep:sha2', + 'dep:thiserror', + 'dep:tokio', + 'dep:tokio-util', + 'dep:uuid', + 'tokio/macros', + 'tokio/rt', + 'tokio/sync', + ], + }, + consumers: new Map([ + ['bitfun-acp', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [capabilityForwarder('server', 'agent-runtime')], + ['server'], + ['default'], + )], + ['bitfun-app-server', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-cli', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-core', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('agent-runtime', 'agent-runtime'), + capabilityForwarder('deep-research', 'deep-research', true), + ], + ['agent-runtime'], + ['external-sources', 'mcp-runtime', 'plugin-runtime', 'product-full', 'remote-connect', 'tools-mcp'], + )], + ['bitfun-desktop', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-product-capabilities', capabilityConsumer([ + capabilityEdge(['agent-runtime'], { kind: 'dev' }), + ])], + ['bitfun-sdk-host', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-sdk-host-app', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-server', capabilityConsumer([ + capabilityEdge(['agent-runtime']), + ])], + ['bitfun-services-integrations', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('deep-research', 'deep-research'), + capabilityForwarder('hook-import', 'native-hook-settings'), + ], + ['deep-research', 'hook-import'], + ['product-full'], + )], + ]), + }, +]; + export const coreProductFullFeatureAssemblyRule = { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'product-full', requiredFeatureRefs: [ 'agent-runtime', + 'diagnostics', + 'diff', + 'document-read', + 'subscription-auth', + 'i18n-runtime', 'browser-control', 'deep-research', 'mcp-runtime', @@ -283,30 +681,141 @@ export const coreProductFullFeatureAssemblyRule = { reason: 'bitfun-core product-full must explicitly assemble current owner feature groups', }; +export const acpClientCoreFeatures = [ + 'agent-runtime', + 'ssh-remote', +]; + +export const acpServerCoreFeatures = [ + 'agent-runtime', + 'document-read', + 'subscription-auth', + 'deep-research', + 'lsp', + 'external-sources', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', +]; + +export const acpClosedFeatureProfileRules = [ + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'default', + requiredFeatureRefs: ['client', 'server'], + exact: true, + reason: 'bitfun-acp default must preserve its complete client and server compatibility surface', + }, + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'client', + requiredFeatureRefs: [ + 'bitfun-agent-tools/acp-bridge', + 'dep:futures', + 'dep:serde', + // The bundled DeepSeek Harness profile: `dirs` locates $DSH_HOME, + // `semver` compares the build's minimum dsh version against the installed + // one, and `tar` packs the profile for a remote workspace, which receives + // it as one stream over its own transport. All three are client-only — + // the server surface never launches an agent. + 'dep:dirs', + 'dep:semver', + 'dep:tar', + 'dep:bitfun-core', + ...acpClientCoreFeatures.map((feature) => `bitfun-core/${feature}`), + ], + exact: true, + reason: 'bitfun-acp client must own only external ACP agent and SSH transport capabilities', + }, + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'server', + requiredFeatureRefs: [ + 'dep:bitfun-agent-tools', + 'dep:bitfun-agent-runtime', + 'bitfun-agent-runtime/agent-runtime', + 'dep:bitfun-core-types', + 'dep:bitfun-core', + 'dep:sha2', + ...acpServerCoreFeatures.map((feature) => `bitfun-core/${feature}`), + ], + exact: true, + reason: 'bitfun-acp server must preserve the reviewed Agent Runtime capability surface without SSH transport', + }, +]; + export const coreClosedFeatureProfileRules = [ + { + manifestPath: 'src/crates/interfaces/app-server/Cargo.toml', + featureName: 'ts', + requiredFeatureRefs: ['bitfun-app-server-protocol/ts'], + exact: true, + reason: 'App Server must delegate TypeScript wire export to the protocol owner', + }, + { + manifestPath: 'src/crates/interfaces/app-server-protocol/Cargo.toml', + featureName: 'ts', + requiredFeatureRefs: [ + 'bitfun-core-types/ts', + 'bitfun-product-domains/ts', + 'bitfun-runtime-ports/ts', + 'dep:ts-rs', + ], + exact: true, + reason: 'App Server Protocol must own the complete behavior-light TypeScript wire surface', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'default', + requiredFeatureRefs: [], + exact: true, + reason: 'bitfun-core default must stay empty so product entrypoints select capabilities explicitly', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'agent-runtime', requiredFeatureRefs: [ 'ai-adapter-runtime', 'dep:bitfun-agent-runtime', + 'bitfun-agent-runtime/agent-runtime', 'dep:bitfun-agent-content', 'dep:bitfun-agent-stream', + 'dep:bitfun-agent-tools', + 'bitfun-agent-tools/computer-use-contract', + 'bitfun-runtime-ports/agent-api', + 'bitfun-runtime-ports/git-port', + 'bitfun-runtime-ports/remote-exec-port', + 'bitfun-runtime-ports/remote-workspace-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/terminal-port', + 'bitfun-runtime-ports/tool-runtime-handles', + 'bitfun-runtime-ports/workspace-ports', + 'dep:base64', 'dep:bitfun-harness', 'dep:dashmap', 'dep:filetime', 'dep:flate2', 'dep:fs2', + 'dep:futures', 'dep:include_dir', 'dep:indexmap', 'dep:image', 'dep:md5', 'dep:rusqlite', + 'dep:regex', 'dep:similar', + 'dep:tokio-util', 'dep:tool-runtime', 'bitfun-services-core/permission', 'bitfun-services-core/runtime-ownership', 'bitfun-services-core/session-git', + 'bitfun-services-core/workspace-text-runtime', 'filesystem', 'local-storage', 'process-runtime', @@ -319,6 +828,10 @@ export const coreClosedFeatureProfileRules = [ 'tool-packs', 'tools-basic', 'tools-agent-control', + 'tokio/io-util', + 'tokio/macros', + 'tokio/rt', + 'tokio/time', ], allowedTransitiveFeatureRefs: [ 'workspace-search', @@ -328,6 +841,28 @@ export const coreClosedFeatureProfileRules = [ reason: 'bitfun-core agent-runtime is the reviewed Core Agent Runtime owner closure, not a product-full alias', }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'i18n-runtime', + requiredFeatureRefs: ['dep:fluent-bundle', 'dep:unic-langid'], + exact: true, + reason: + 'i18n-runtime must own only the backend Fluent bundle and language identifier implementation', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'diagnostics', + requiredFeatureRefs: ['bitfun-services-core/diagnostics'], + exact: true, + reason: 'bitfun-core diagnostics must preserve only the reusable diagnostic redaction facade', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'diff', + requiredFeatureRefs: ['bitfun-services-core/diff'], + exact: true, + reason: 'bitfun-core diff must preserve only the reusable local diff facade', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'external-sources', @@ -337,6 +872,7 @@ export const coreClosedFeatureProfileRules = [ 'mcp-runtime', 'script-tool-runtime', 'dep:bitfun-opencode-adapter', + 'dep:bitfun-dsh-adapter', 'dep:bitfun-claude-code-adapter', 'dep:bitfun-codex-adapter', 'dep:bitfun-external-sources', @@ -372,7 +908,11 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'plugin-runtime', - requiredFeatureRefs: ['external-sources', 'dep:bitfun-plugin-runtime-client'], + requiredFeatureRefs: [ + 'external-sources', + 'dep:bitfun-plugin-runtime-client', + 'bitfun-runtime-ports/plugin-runtime', + ], allowedTransitiveFeatureRefs: [ 'agent-runtime', 'model-catalog', @@ -419,6 +959,7 @@ export const coreClosedFeatureProfileRules = [ featureName: 'mcp-runtime', requiredFeatureRefs: [ 'agent-runtime', + 'bitfun-agent-tools/mcp-bridge', 'dep:axum', 'dep:reqwest', 'bitfun-services-integrations/mcp', @@ -483,7 +1024,13 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'browser-control', - requiredFeatureRefs: ['dep:tokio-tungstenite', 'bitfun-services-integrations/browser-control'], + requiredFeatureRefs: [ + 'dep:tokio-tungstenite', + 'bitfun-services-integrations/browser-control', + 'tokio/net', + 'tokio/rt', + 'tokio/time', + ], exact: true, reason: 'browser-control must own only the CDP browser adapter', }, @@ -497,14 +1044,20 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'deep-research', - requiredFeatureRefs: ['bitfun-services-integrations/deep-research'], + requiredFeatureRefs: [ + 'bitfun-agent-runtime?/deep-research', + 'bitfun-services-integrations/deep-research', + ], exact: true, reason: 'deep-research must own only research report post-processing', }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'script-tool-runtime', - requiredFeatureRefs: ['bitfun-services-integrations/script-tool-runtime'], + requiredFeatureRefs: [ + 'bitfun-runtime-ports/script-tool-runtime', + 'bitfun-services-integrations/script-tool-runtime', + ], exact: true, reason: 'script-tool-runtime must own only external script tool execution support', }, @@ -515,12 +1068,35 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'scheduled-jobs is an additive Agent Runtime modifier for cron parsing and timezone scheduling', }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'document-read', + requiredFeatureRefs: ['tool-runtime?/document-read'], + exact: true, + reason: + 'document-read must add conversion only when the Agent tool runtime owner is selected', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'subscription-auth', + requiredFeatureRefs: ['bitfun-ai-adapters?/subscription-auth'], + exact: true, + reason: + 'subscription-auth must add local credential resolution only when the AI adapter runtime owner is selected', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'ai-adapter-runtime', + requiredFeatureRefs: ['dep:bitfun-ai-adapters'], + exact: true, + reason: + 'ai-adapter-runtime must own provider protocol clients without implicitly enabling local subscription credentials', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'tools-basic', requiredFeatureRefs: [ 'bitfun-tool-packs/basic', - 'tool-runtime/document-read', 'workspace-search', ], allowedTransitiveFeatureRefs: [ @@ -665,8 +1241,13 @@ export const coreClosedFeatureProfileRules = [ featureName: 'debug-log', requiredFeatureRefs: [ 'dep:axum', + 'dep:tokio-util', 'dep:tower-http', 'bitfun-services-integrations/debug-log', + 'tokio/macros', + 'tokio/net', + 'tokio/rt', + 'tokio/time', ], exact: true, reason: 'bitfun-core debug-log must own only the debug ingest HTTP capability', @@ -678,10 +1259,33 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'services-core default profile must stay empty so consumers select capabilities explicitly', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'diagnostics', + requiredFeatureRefs: ['dep:regex'], + exact: true, + reason: 'services-core diagnostics must own only deterministic diagnostic-log redaction', + }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'diff', + requiredFeatureRefs: ['dep:similar', 'dep:tokio', 'tokio/rt', 'tokio/time'], + exact: true, + reason: 'services-core diff must own only local text diff calculation and its bounded async runtime', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'filesystem', - requiredFeatureRefs: ['dep:base64', 'dep:chrono', 'dep:ignore', 'dep:sha2', 'tokio/fs'], + requiredFeatureRefs: [ + 'dep:base64', + 'dep:chrono', + 'dep:ignore', + 'dep:regex', + 'dep:sha2', + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + ], exact: true, reason: 'services-core filesystem must own only local file operations and recursive search dependencies', }, @@ -690,9 +1294,12 @@ export const coreClosedFeatureProfileRules = [ featureName: 'json-io', requiredFeatureRefs: [ 'dep:fs2', + 'dep:tokio', 'dep:windows', 'tokio/fs', + 'tokio/rt', 'tokio/sync', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_Storage_FileSystem', ], @@ -708,10 +1315,15 @@ export const coreClosedFeatureProfileRules = [ 'dep:chrono', 'dep:fs2', 'dep:libc', + 'dep:regex', 'dep:sha2', + 'dep:similar', + 'dep:tokio', 'dep:windows', 'tokio/fs', + 'tokio/rt', 'tokio/sync', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_Storage_FileSystem', ], @@ -723,11 +1335,14 @@ export const coreClosedFeatureProfileRules = [ featureName: 'process-runtime', requiredFeatureRefs: [ 'dep:libc', + 'dep:tokio', 'dep:which', 'dep:win32job', 'dep:windows', 'tokio/io-util', 'tokio/process', + 'tokio/rt', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', 'windows/Win32_System_Threading', @@ -738,10 +1353,25 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'workspace-instructions', - requiredFeatureRefs: ['dep:globset', 'dep:serde_yaml', 'tokio/fs', 'tokio/io-util'], + requiredFeatureRefs: [ + 'dep:globset', + 'dep:regex', + 'dep:serde_yaml', + 'dep:tokio', + 'tokio/fs', + 'tokio/io-util', + 'tokio/rt', + ], exact: true, reason: 'services-core workspace-instructions must own declarative instruction discovery, scope parsing, and glob expansion only', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'workspace-text-runtime', + requiredFeatureRefs: ['dep:tokio', 'tokio/rt'], + exact: true, + reason: 'services-core workspace-text-runtime must own only bounded asynchronous local workspace reads', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'lsp', @@ -765,6 +1395,8 @@ export const coreClosedFeatureProfileRules = [ 'dep:anyhow', 'dep:async-trait', 'dep:bitfun-runtime-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/workspace-ports', 'dep:dunce', 'process-runtime', 'tokio/fs', @@ -791,7 +1423,11 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'dispatch-store', - requiredFeatureRefs: ['local-storage', 'bitfun-services-core/dispatch-workspace'], + requiredFeatureRefs: [ + 'dep:base64', + 'local-storage', + 'bitfun-services-core/dispatch-workspace', + ], exact: true, reason: 'bitfun-core dispatch-store must expose only the durable dispatch index facade', }, @@ -805,7 +1441,7 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'local-storage', - requiredFeatureRefs: ['bitfun-services-core/local-storage'], + requiredFeatureRefs: ['dep:bitfun-agent-tools', 'bitfun-services-core/local-storage'], exact: true, reason: 'bitfun-core local-storage must select only reusable local persistence owners', }, @@ -819,7 +1455,7 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'lsp', - requiredFeatureRefs: ['dep:notify', 'bitfun-services-core/lsp'], + requiredFeatureRefs: ['dep:notify', 'bitfun-services-core/lsp', 'tokio/macros'], exact: true, reason: 'bitfun-core lsp must select only the LSP owner and its workspace watcher dependency', }, @@ -941,6 +1577,13 @@ export const ownerCrateFeatureAssemblyRules = [ { manifestPath: 'src/crates/services/services-integrations/Cargo.toml', reason: 'services-integrations must keep integration feature groups explicit and default-light', + optionalDependencyAggregateFeatures: [ + 'function-agents', + 'mcp', + 'miniapp-market', + 'remote-ssh-concrete', + 'script-tool-runtime', + ], requiredProductFullFeatures: [ 'announcement', 'browser-control', @@ -970,3 +1613,23 @@ export const ownerCrateFeatureAssemblyRules = [ requiredProductFullFeatures: ['appearance-market', 'plugin-source', 'miniapp', 'function-agents', 'external-sources'], }, ]; + +export function reviewedOptionalDependencyAggregateFeatures(manifestPath) { + const features = new Set( + coreClosedFeatureProfileRules + .filter((profile) => profile.manifestPath === manifestPath) + .map((profile) => profile.featureName), + ); + if (coreProductFullFeatureAssemblyRule.manifestPath === manifestPath) { + features.add(coreProductFullFeatureAssemblyRule.featureName); + } + const ownerAssembly = ownerCrateFeatureAssemblyRules.find((profile) => + profile.manifestPath === manifestPath); + if (ownerAssembly) { + features.add('product-full'); + for (const feature of ownerAssembly.optionalDependencyAggregateFeatures ?? []) { + features.add(feature); + } + } + return features; +} diff --git a/scripts/core-boundaries/rules/source-rules.mjs b/scripts/core-boundaries/rules/source-rules.mjs index a318928e7c..edbc0afd5e 100644 --- a/scripts/core-boundaries/rules/source-rules.mjs +++ b/scripts/core-boundaries/rules/source-rules.mjs @@ -5,5 +5,9 @@ export { forbiddenContentRules, forbiddenContentUnderRules, } from './source/forbidden-rules.mjs'; -export { publicApiAllowlistRules, publicApiContractSlices } from './source/public-api-rules.mjs'; +export { + agentRuntimeRootPublicModules, + publicApiAllowlistRules, + publicApiContractSlices, +} from './source/public-api-rules.mjs'; export { requiredContentRules } from './source/required-rules.mjs'; diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index d6eaab22e7..19e09ed0d1 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1,6 +1,24 @@ // Boundary rules for source ownership, facades, and required owner content. +import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; + +const agentRuntimeRootUnexpectedLine = new RegExp( + `^(?!(?:[ \\t]*|[ \\t]*\\/\\/!.*|[ \\t]*#\\[cfg\\(feature = "(?:agent-runtime|deep-research|native-hook-settings)"\\)\\][ \\t]*|[ \\t]*pub mod (?:${agentRuntimeRootPublicModules.join('|')});[ \\t]*)\\r?$).+$`, + 'm', +); + export const forbiddenContentRules = [ + { + path: 'src/crates/execution/agent-runtime/src/lib.rs', + reason: + 'Agent Runtime root is a flat feature-owned module wrapper, not a feature-free implementation surface', + patterns: [ + { + regex: agentRuntimeRootUnexpectedLine, + message: 'unexpected Agent Runtime root content outside the reviewed cfg/module pairs', + }, + ], + }, { path: 'src/apps/cli/src/tui_backend.rs', reason: @@ -35,6 +53,19 @@ export const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/adapters/agent-runtime-ipc/Cargo.toml', + reason: + 'agent-runtime-ipc may reuse bounded JSON encoding without enabling bitfun-transport features', + patterns: [ + { + regex: + /bitfun-transport\s*=\s*\{[^}\r\n]*(?:features|default-features)\s*=/, + message: + 'agent-runtime-ipc must not enable event-host or Tauri transport features', + }, + ], + }, { path: 'src/crates/execution/plugin-runtime-client/src/adapter.rs', reason: 'plugin-runtime-client adapter trait method surface must stay narrow', @@ -216,7 +247,7 @@ export const forbiddenContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', patterns: [ { regex: /\bbitfun_core\b/, @@ -3260,7 +3291,7 @@ export const forbiddenContentRules = [ 'Git function-agent commit generator must use CoreProductDomainRuntime for Git adapter wiring', }, { - regex: /\bAIAnalysisService::new_with_agent_config\b/, + regex: /\bAIAnalysisService::new_with_task_config\b/, message: 'Git function-agent commit generator must use CoreProductDomainRuntime for AI adapter wiring', }, @@ -3271,21 +3302,6 @@ export const forbiddenContentRules = [ }, ], }, - { - path: 'src/crates/assembly/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', - patterns: [ - { - regex: /\bAIWorkStateService::new_with_agent_config\b/, - message: - 'Startchat work-state analyzer must use CoreProductDomainRuntime for AI adapter wiring', - }, - { - regex: /\bcreate_command\("git"\)/, - message: - 'Startchat work-state analyzer must use CoreProductDomainRuntime for Git adapter wiring', - }, - ], - }, { path: 'src/crates/assembly/core/src/service/mcp/server/config.rs', patterns: [ @@ -4058,6 +4074,12 @@ export const forbiddenContentUnderRules = [ /\b(?:(?:Tcp|Udp)[A-Za-z0-9_]*|tokio_tungstenite|reqwest|hyper|WebSocket)\b/, message: 'agent-runtime-ipc must not add network or remote transports', }, + { + regex: + /\bbitfun_transport\b(?!::(?:encode_json_with_limit|JsonCodecError)\b)/, + message: + 'agent-runtime-ipc may consume only the reviewed bounded JSON API from bitfun-transport', + }, ], }, { @@ -4135,13 +4157,13 @@ export const forbiddenContentUnderRules = [ /\b(?:use\s+bitfun_opencode_adapter\b|extern\s+crate\s+bitfun_opencode_adapter\b|bitfun_opencode_adapter::)/, allowPaths: [ 'src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_workspace_references.rs', 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/hook_source.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/hook_source.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', @@ -4171,10 +4193,10 @@ export const forbiddenContentUnderRules = [ patterns: [{ regex: /\b(?:use\s+bitfun_claude_code_adapter\b|extern\s+crate\s+bitfun_claude_code_adapter\b|bitfun_claude_code_adapter::)/, allowPaths: [ - 'src/crates/adapters/claude-code-adapter/tests/hook_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/command_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/subagent_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/mcp_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/hook_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/command_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', @@ -4188,9 +4210,9 @@ export const forbiddenContentUnderRules = [ patterns: [{ regex: /\b(?:use\s+bitfun_codex_adapter\b|extern\s+crate\s+bitfun_codex_adapter\b|bitfun_codex_adapter::)/, allowPaths: [ - 'src/crates/adapters/codex-adapter/tests/hook_source.rs', - 'src/crates/adapters/codex-adapter/tests/subagent_source.rs', - 'src/crates/adapters/codex-adapter/tests/mcp_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/hook_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/subagent_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', @@ -4207,6 +4229,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/static-hook-support/tests/parser.rs', 'src/crates/adapters/opencode-adapter/src/hook_source.rs', 'src/crates/adapters/opencode-adapter/src/command_source.rs', + 'src/crates/adapters/opencode-adapter/src/agent_source.rs', 'src/crates/adapters/opencode-adapter/src/mcp_source.rs', 'src/crates/adapters/claude-code-adapter/src/hook_source.rs', 'src/crates/adapters/claude-code-adapter/src/command_source.rs', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 1fe145be9a..387c542529 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -16,6 +16,48 @@ export const publicApiContractSlices = [ 'external-integration-policy-contract', ]; +export const agentRuntimeRootPublicModules = [ + 'agents', + 'checkpoint', + 'context_profile', + 'custom_agent', + 'custom_subagent', + 'deep_research', + 'deep_review', + 'dialog_turn', + 'event_bus', + 'event_queue', + 'event_router', + 'event_source', + 'events', + 'evidence_ledger', + 'file_read_state', + 'native_hooks', + 'output_surface', + 'permission', + 'post_call_hooks', + 'prompt', + 'prompt_cache', + 'prompt_markup', + 'remote_file_delivery', + 'runtime', + 'scheduled_job', + 'scheduler', + 'sdk', + 'session', + 'session_control', + 'session_state', + 'session_state_manager', + 'side_question', + 'skill_agent_snapshot', + 'skills', + 'subagent_task', + 'thread_goal', + 'thread_goal_tools', + 'turn_cancellation', + 'user_questions', +]; + const contractSlices = { frontendBackendCapabilityService: 'frontend-backend-capability-service', bitfunPluginExtension: 'bitfun-plugin-extension-contract', @@ -319,6 +361,23 @@ function staticSourceSupportEntry(symbol) { }; } +function commonExternalSubagentToolMappingEntry(symbol) { + return { + symbol, + owner: 'static-hook-support shared declarative source adapter utility owner', + consumer: 'reviewed OpenCode and Claude Code declarative subagent adapters', + verification: + 'shared mapping unit tests, ecosystem subagent adapter fixtures, and core-boundary public API budget checks', + p0: 'runtime-free common external Agent tool capability normalization', + contractSlice: contractSlices.externalSourceControlContract, + wireImpact: false, + rationale: + 'sibling declarative adapters need one static mapping while provider-specific aliases remain adapter-owned', + exit: + 'remove only if every reviewed consumer moves to an equivalent adapter-layer mapping owner', + }; +} + function declarativeSourceAdapterEntry( symbol, owner, @@ -477,7 +536,9 @@ export const staticHookSupportPublicApiEntries = [ 'BoundedDirectoryWalkLimit', 'BoundedDirectoryWalkError', 'collect_bounded_regular_files', -].map(staticSourceSupportEntry)); +].map(staticSourceSupportEntry)).concat([ + 'common_external_subagent_tool_capability', +].map(commonExternalSubagentToolMappingEntry)); function externalHookContractEntry(symbol, owner, consumer, wireImpact = false) { return { @@ -868,6 +929,7 @@ export const externalSubagentContractPublicApiEntries = [ 'ExternalSubagentModelBindingMethod', 'ExternalSubagentModelBindingOption', 'ExternalSubagentModelBindingGroup', + 'ExternalSubagentToolCapability', 'ExternalSubagentToolSelector', 'ExternalSubagentToolRequest', 'ExternalSubagentCompatibilityState', @@ -1102,6 +1164,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalToolConflictCandidateKind', 'ExternalToolRuntimeKind', 'set_external_tool_target_decision', + 'set_external_tool_targets_enabled', 'set_external_tool_conflict_choice', ].map((symbol) => externalToolEntry( @@ -1123,6 +1186,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalSubagentModelRequest', 'ExternalSubagentSummary', 'set_external_subagent_activation', + 'set_external_subagents_enabled', 'set_external_subagent_model_binding', 'choose_external_subagent_conflict', ].map((symbol) => @@ -1140,6 +1204,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalMcpTransportKind', 'native_mcp_candidate_id', 'set_external_mcp_server_decision', + 'set_external_mcp_servers_enabled', 'choose_external_mcp_conflict', ].map((symbol) => externalMcpEntry( @@ -1241,6 +1306,12 @@ export const managedPluginSourceServicePublicApiEntries = [ ); export const publicApiAllowlistRules = [ + { + path: 'src/crates/execution/agent-runtime/src/lib.rs', + reason: + 'Agent Runtime root must expose only the reviewed feature-owned capability modules', + allowedSymbols: agentRuntimeRootPublicModules, + }, { path: 'src/crates/contracts/runtime-ports/src/plugin.rs', reason: diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 5510e2d0ad..80e882a646 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1,49 +1,51 @@ // Boundary rules for source ownership, facades, and required owner content. +import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; + export const requiredContentRules = [ { - path: 'Cargo.toml', + path: 'src/crates/adapters/agent-runtime-ipc/Cargo.toml', reason: - 'workspace Reqwest defaults must stay transport-only so client owners select one TLS backend explicitly', + 'agent-runtime-ipc must keep the exact feature-free bitfun-transport dependency', patterns: [ { - regex: /^reqwest[ \t]*=[ \t]*\{[ \t]*version[ \t]*=[ \t]*"[^"]+",[ \t]*default-features[ \t]*=[ \t]*false,[ \t]*features[ \t]*=[ \t]*\[[ \t]*"http2",[ \t]*"json",[ \t]*"stream",[ \t]*"multipart",[ \t]*"query",[ \t]*"form"[ \t]*\][ \t]*\}[ \t]*$/m, + regex: /^bitfun-transport = \{ path = "\.\.\/transport" \}$/m, message: - 'workspace Reqwest dependency must use the reviewed transport/data feature allowlist', + 'bitfun-transport must stay feature-free and use the reviewed sibling path', }, ], }, ...[ 'src/apps/cli/Cargo.toml', 'src/apps/desktop/Cargo.toml', + 'src/crates/adapters/ai-adapters/Cargo.toml', + 'src/crates/assembly/core/Cargo.toml', 'src/crates/services/miniapp-market-service/Cargo.toml', + 'src/crates/services/services-integrations/Cargo.toml', 'src/crates/services/skin-market-service/Cargo.toml', ].map((path) => ({ path, - reason: 'first-party Reqwest client owners must select the repository TLS backend explicitly', + reason: 'first-party Reqwest consumers must inherit the workspace-owned compatible version', patterns: [ { - regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true,\s*features\s*=\s*\[\s*"rustls"\s*\]\s*\}/m, - message: 'Reqwest client dependency must explicitly enable rustls', + regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true(?:\s*,|\s*\})/m, + message: 'Reqwest dependency must use workspace = true', }, ], })), - { - path: 'src/crates/adapters/ai-adapters/Cargo.toml', - reason: - 'the AI adapter owns Rustls HTTPS clients and the product-supported SOCKS proxy transport', - patterns: [ - { - regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true,\s*features\s*=\s*\[\s*"rustls"\s*,\s*"socks"\s*\]\s*\}/m, - message: 'AI adapter Reqwest dependency must explicitly enable rustls and socks', - }, - ], - }, { path: 'src/crates/services/services-core/src/lib.rs', reason: 'services-core must compile concrete service owners only through their declared capability features', patterns: [ + { + regex: /#\[cfg\(feature = "diagnostics"\)\]\s*pub mod diagnostics;/, + message: 'missing diagnostic redaction capability source gate', + }, + { + regex: /#\[cfg\(feature = "diff"\)\]\s*pub mod diff;/, + message: 'missing local diff capability source gate', + }, { regex: /#\[cfg\(any\(feature = "local-storage", feature = "runtime-ownership"\)\)\]\s*mod file_lock;/, message: 'missing shared file lock owner source gate', @@ -98,6 +100,25 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/services/services-core/src/workspace_text.rs', + reason: + 'workspace text contracts stay synchronous while bounded local IO requires its runtime owner', + patterns: [ + { + regex: /pub fn normalize_workspace_relative_path\b/, + message: 'missing feature-free workspace path normalization contract', + }, + { + regex: /#\[cfg\(feature = "workspace-text-runtime"\)\]\s*pub async fn read_workspace_relative_text_bounded\b/s, + message: 'bounded workspace text reads must stay behind workspace-text-runtime', + }, + { + regex: /#\[cfg\(feature = "workspace-text-runtime"\)\]\s*pub async fn resolve_workspace_relative_entry\b/s, + message: 'workspace entry resolution must stay behind workspace-text-runtime', + }, + ], + }, { path: 'src/crates/services/services-core/src/persistence.rs', reason: @@ -207,7 +228,7 @@ export const requiredContentRules = [ message: 'serde_yaml must remain optional in services-core', }, { - regex: /markdown = \["dep:serde_yaml"\]/, + regex: /markdown = \[[^\]]*"dep:serde_yaml"[^\]]*\]/, message: 'missing explicit markdown feature for services-core', }, ], @@ -452,7 +473,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/core-types/tests/lsp_contracts.rs', + path: 'src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs', reason: 'core-types must keep LSP manifest serialization, default-value, and placeholder regressions', patterns: [ @@ -867,6 +888,10 @@ export const requiredContentRules = [ regex: /default = \[\]/, message: 'agent-runtime default feature set must stay empty', }, + { + regex: /\[\[example\]\]\r?\nname = "sdk_minimal"\r?\npath = "examples\/sdk_minimal\.rs"\r?\nrequired-features = \["agent-runtime"\]/, + message: 'sdk_minimal example must declare the versioned agent-runtime owner feature', + }, ], }, { @@ -1533,7 +1558,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', reason: 'product-capabilities tests must protect product shape facts, runtime service gap reporting, and legacy harness routing', patterns: [ @@ -1564,7 +1589,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', reason: 'product-capabilities plugin shape tests must protect P0 plugin-capable profiles, non-P0 rejection, default availability reasons, and runtime handoff', patterns: [ @@ -1591,7 +1616,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', reason: 'product-capabilities must prove product runtime parts can feed the SDK runtime without bitfun-core', patterns: [ @@ -2766,7 +2791,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/deep_research_contracts.rs', + path: 'src/crates/execution/agent-runtime/tests/deep_research_contracts.rs', reason: 'agent-runtime must keep behavior-equivalence contracts for DeepResearch citation renumbering', patterns: [ @@ -3917,12 +3942,12 @@ export const requiredContentRules = [ patterns: [ { regex: - /bitfun-tool-packs = \{ path = "\.\.\/\.\.\/execution\/tool-provider-groups", default-features = false, optional = true \}/, + /bitfun-tool-packs = \{ path = "\.\.\/\.\.\/execution\/tool-provider-groups", optional = true \}/, message: 'bitfun-tool-packs dependency must stay optional and not force product-full outside the core feature graph', }, { regex: - /bitfun-services-integrations = \{ path = "\.\.\/\.\.\/services\/services-integrations", default-features = false, optional = true \}/, + /bitfun-services-integrations = \{ path = "\.\.\/\.\.\/services\/services-integrations", optional = true \}/, message: 'bitfun-services-integrations dependency must stay optional so local workspace profiles do not compile remote integrations', }, @@ -3935,6 +3960,14 @@ export const requiredContentRules = [ regex: /"dep:bitfun-ai-adapters"/, message: 'core ai-adapter-runtime feature must explicitly enable the optional dependency', }, + { + regex: /subscription-auth = \["bitfun-ai-adapters\?\/subscription-auth"\]/, + message: 'core subscription-auth modifier must not activate the optional AI adapter runtime by itself', + }, + { + regex: /document-read = \["tool-runtime\?\/document-read"\]/, + message: 'core document-read modifier must not activate the optional tool runtime by itself', + }, { regex: /agent-runtime = \[[^\]]*"ai-adapter-runtime"[^\]]*\]/, message: 'core agent-runtime assembly must explicitly opt into AI adapter runtime', @@ -3972,13 +4005,13 @@ export const requiredContentRules = [ }, { regex: - /bitfun-product-domains = \{ path = "\.\.\/\.\.\/contracts\/product-domains", default-features = false, optional = true \}/, + /bitfun-product-domains = \{ path = "\.\.\/\.\.\/contracts\/product-domains", optional = true \}/, message: 'bitfun-product-domains dependency must stay optional and not force product-full outside the core feature graph', }, { regex: - /bitfun-product-capabilities = \{ path = "\.\.\/product-capabilities", default-features = false, optional = true \}/, + /bitfun-product-capabilities = \{ path = "\.\.\/product-capabilities", optional = true \}/, message: 'bitfun-product-capabilities dependency must stay optional and not force product-full outside the core feature graph', }, @@ -4071,8 +4104,8 @@ export const requiredContentRules = [ message: 'AI client runtime must stay behind ai-adapter-runtime', }, { - regex: /#\[cfg\(feature = "ai-adapter-runtime"\)\]\s*pub mod subscription_auth\b/s, - message: 'AI subscription auth runtime must stay behind ai-adapter-runtime', + regex: /#\[cfg\(all\(feature = "ai-adapter-runtime", feature = "subscription-auth"\)\)\]\s*pub mod subscription_auth\b/s, + message: 'AI subscription auth runtime must require both the adapter and credential owners', }, { regex: /#\[cfg\(feature = "debug-log"\)\]\s*pub mod debug_log\b/s, @@ -4107,6 +4140,14 @@ export const requiredContentRules = [ regex: /#\[cfg\(feature = "file-watch"\)\]\s*pub use bitfun_services_integrations::file_watch\b/s, message: 'file-watch facade must stay behind its exact feature', }, + { + regex: /#\[cfg\(feature = "diagnostics"\)\]\s*pub use bitfun_services_core::diagnostics\b/s, + message: 'diagnostics compatibility facade must stay behind diagnostics', + }, + { + regex: /#\[cfg\(feature = "diff"\)\]\s*pub use bitfun_services_core::diff\b/s, + message: 'diff compatibility facade must stay behind diff', + }, { regex: /#\[cfg\(feature = "git"\)\]\s*pub mod git\b/s, message: 'git service facade must stay behind its exact feature', @@ -4855,7 +4896,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs', reason: 'runtime-ports plugin contract tests must cover typed envelopes, candidate effects, and disabled/projection-only behavior', patterns: [ @@ -4912,7 +4953,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', reason: 'runtime-ports plugin diagnostics contract tests must cover permission prompts, diagnostics, and quarantine facts', patterns: [ @@ -5035,7 +5076,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/src/lib.rs', + path: 'src/crates/contracts/runtime-ports/src/agent_api.rs', reason: 'runtime-ports must keep remote and subagent runtime boundary contracts DTO/trait-only', patterns: [ @@ -5048,6 +5089,7 @@ export const requiredContentRules = [ message: 'missing remote control state port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/runtime_event_port.rs', regex: /\bpub trait RuntimeEventSink\b/, message: 'missing runtime event sink contract', }, @@ -5056,62 +5098,87 @@ export const requiredContentRules = [ message: 'agent session create result must return the persisted session name', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub struct RemoteWorkspaceFacts\b/, message: 'missing remote workspace facts contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteWorkspaceRuntimeHost\b/, message: 'missing remote workspace runtime host contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteWorkspacePort\b/, message: 'missing remote workspace service port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteWorkspaceFileRuntimeHost\b/, message: 'missing remote workspace file runtime host contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteProjectionPort\b/, message: 'missing remote projection service port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteInitialSyncRuntimeHost\b/, message: 'missing remote initial sync runtime host contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bremote_workspace_contracts_preserve_workspace_and_session_facts\b/, message: 'missing remote workspace contract regression', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bremote_projection_contract_preserves_file_chunk_identity\b/, message: 'missing remote projection contract regression', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub trait WorkspaceFileSystem\b/, message: 'missing workspace file-system port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', + regex: /\bfn join_path\(&self, root: &str, components: &\[&str\]\) -> String\b/, + message: 'workspace filesystem providers must own their path joining syntax', + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs', + regex: /fn join_path\(&self, root: &str, components: &\[&str\]\) -> String \{\r?\n\s*join_posix_path\(root, components\)/, + message: 'remote workspace filesystem must keep POSIX path joining independent of the host', + }, + { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub trait WorkspaceShell\b/, message: 'missing workspace shell port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub struct WorkspaceServices\b/, message: 'missing workspace services bundle contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub struct WorkspaceCommandOptions\b/, message: 'missing workspace command options contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub struct WorkspaceCommandResult\b/, message: 'missing workspace command result contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub struct WorkspaceDirEntry\b/, message: 'missing workspace dir-entry contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bworkspace_services_contract_is_runtime_port_owned\b/, message: 'missing workspace service ownership regression', }, @@ -5188,10 +5255,12 @@ export const requiredContentRules = [ message: 'missing thread-goal lifecycle request regression', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bpub type DialogTriggerSource = AgentSubmissionSource\b/, message: 'missing dialog trigger source compatibility contract', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bdialog_trigger_source_reuses_agent_submission_source_contract\b/, message: 'missing dialog trigger source alias regression', }, @@ -5332,31 +5401,111 @@ export const requiredContentRules = [ message: 'missing compression contract rendering regression', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bpub struct RelatedPath\b/, message: 'missing related path request-context contract', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\brelated_path_serializes_as_request_context_fact\b/, message: 'missing related path serialization regression', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bpub struct DelegationPolicy\b/, message: 'missing delegation policy contract', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bpub enum SubagentContextMode\b/, message: 'missing subagent context mode contract', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bdelegation_policy_child_blocks_recursive_spawn_without_losing_depth\b/, message: 'missing delegation policy contract regression', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bsubagent_context_mode_preserves_fork_wire_value\b/, message: 'missing subagent context mode contract regression', }, ], }, + { + path: 'src/crates/execution/agent-runtime/src/lib.rs', + reason: 'Agent Runtime leaf capability modules must stay behind their exact owner features', + patterns: [ + { + regex: /#\[cfg\(feature = "deep-research"\)\]\r?\npub mod deep_research;/, + message: 'deep-research must gate its pure report capability', + }, + { + regex: /#\[cfg\(feature = "native-hook-settings"\)\]\r?\npub mod native_hooks;/, + message: 'native-hook-settings must gate the portable hook facade', + }, + ...agentRuntimeRootPublicModules + .filter((moduleName) => !['deep_research', 'native_hooks'].includes(moduleName)) + .map((moduleName) => ({ + regex: new RegExp(`#\\[cfg\\(feature = "agent-runtime"\\)\\]\\r?\\npub mod ${moduleName};`), + message: `${moduleName} must stay behind the full agent-runtime owner`, + })), + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/native_hooks/mod.rs', + reason: 'native hook execution modules must stay behind native-hook-runtime', + patterns: [ + ...['engine', 'output', 'payload'].flatMap((moduleName) => [ + { + regex: new RegExp(`#\\[cfg\\(feature = "native-hook-runtime"\\)\\]\\r?\\nmod ${moduleName};`), + message: `${moduleName} must stay behind native-hook-runtime`, + }, + { + regex: new RegExp(`#\\[cfg\\(feature = "native-hook-runtime"\\)\\]\\r?\\npub use ${moduleName}(?:::|\\{)`), + message: `${moduleName} exports must stay behind native-hook-runtime`, + }, + ]), + ], + }, + { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', + reason: 'runtime-ports capability features must gate their owned source modules and exports', + patterns: [ + { regex: /#\[cfg\(feature = "agent-api"\)\]\r?\nmod agent_api;/, message: 'agent-api must gate its source module' }, + { regex: /#\[cfg\(feature = "agent-api"\)\]\r?\npub use agent_api::\*;/, message: 'agent-api must gate its public exports' }, + { regex: /#\[cfg\(feature = "plugin-runtime"\)\]\r?\nmod plugin;/, message: 'plugin-runtime must gate its source module' }, + { regex: /#\[cfg\(feature = "plugin-runtime"\)\]\r?\npub use plugin::\{/, message: 'plugin-runtime must gate its public exports' }, + { regex: /#\[cfg\(feature = "script-tool-runtime"\)\]\r?\nmod script_tool;/, message: 'script-tool-runtime must gate its source module' }, + { regex: /#\[cfg\(feature = "script-tool-runtime"\)\]\r?\npub use script_tool::\{/, message: 'script-tool-runtime must gate its public exports' }, + { regex: /#\[cfg\(feature = "workspace-ports"\)\]\r?\nmod workspace_ports;/, message: 'workspace-ports must gate its source module' }, + { regex: /#\[cfg\(feature = "workspace-ports"\)\]\r?\npub use workspace_ports::\*;/, message: 'workspace-ports must gate its public exports' }, + { regex: /#\[cfg\(feature = "terminal-port"\)\]\r?\nmod terminal_port;/, message: 'terminal-port must gate its source module' }, + { regex: /#\[cfg\(feature = "terminal-port"\)\]\r?\npub use terminal_port::\*;/, message: 'terminal-port must gate its public exports' }, + { regex: /#\[cfg\(feature = "remote-exec-port"\)\]\r?\nmod remote_exec_port;/, message: 'remote-exec-port must gate its source module' }, + { regex: /#\[cfg\(feature = "remote-exec-port"\)\]\r?\npub use remote_exec_port::\*;/, message: 'remote-exec-port must gate its public exports' }, + { regex: /#\[cfg\(feature = "remote-workspace-ports"\)\]\r?\nmod remote_workspace_ports;/, message: 'remote-workspace-ports must gate its source module' }, + { regex: /#\[cfg\(feature = "remote-workspace-ports"\)\]\r?\npub use remote_workspace_ports::\*;/, message: 'remote-workspace-ports must gate its public exports' }, + { regex: /#\[cfg\(feature = "runtime-event-port"\)\]\r?\nmod runtime_event_port;/, message: 'runtime-event-port must gate its source module' }, + { regex: /#\[cfg\(feature = "runtime-event-port"\)\]\r?\npub use runtime_event_port::\*;/, message: 'runtime-event-port must gate its public exports' }, + { regex: /#\[cfg\(feature = "git-port"\)\]\r?\nmod git_port;/, message: 'git-port must gate its source module' }, + { regex: /#\[cfg\(feature = "git-port"\)\]\r?\npub use git_port::\*;/, message: 'git-port must gate its public exports' }, + { regex: /#\[cfg\(feature = "tool-runtime-handles"\)\]\r?\nmod tool_runtime_handles;/, message: 'tool-runtime-handles must gate its source module' }, + { regex: /#\[cfg\(feature = "tool-runtime-handles"\)\]\r?\npub use tool_runtime_handles::\*;/, message: 'tool-runtime-handles must gate its public exports' }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/lib.rs', + reason: 'tool-contract capability features must gate their owned source modules and exports', + patterns: [ + { regex: /#\[cfg\(feature = "acp-bridge"\)\]\r?\npub mod acp_tool_bridge;/, message: 'acp-bridge must gate its source module' }, + { regex: /#\[cfg\(feature = "acp-bridge"\)\]\r?\npub use acp_tool_bridge::\{/, message: 'acp-bridge must gate its public exports' }, + { regex: /#\[cfg\(feature = "mcp-bridge"\)\]\r?\npub mod mcp_tool_bridge;/, message: 'mcp-bridge must gate its source module' }, + { regex: /#\[cfg\(feature = "mcp-bridge"\)\]\r?\npub use mcp_tool_bridge::\{/, message: 'mcp-bridge must gate its public exports' }, + { regex: /#\[cfg\(feature = "computer-use-contract"\)\]\r?\npub mod computer_use;/, message: 'computer-use-contract must gate its source module' }, + { regex: /#\[cfg\(feature = "element-token"\)\]\r?\npub mod element_token;/, message: 'element-token must gate its source module' }, + ], + }, { path: 'src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs', reason: @@ -9604,7 +9753,7 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/function_agents/port_adapters.rs', reason: - 'core function-agent port adapters must continue owning AI concrete calls while product-domains owns prompt, parser, and facade policy', + 'core function-agent port adapters must continue owning AI concrete calls while product-domains owns Git prompt, parser, and facade policy', patterns: [ { regex: /\bprepare_commit_ai_prompt\b/, @@ -9614,14 +9763,6 @@ export const requiredContentRules = [ regex: /\bparse_commit_ai_response\b/, message: 'missing product-domain Git function-agent response policy use', }, - { - regex: /\bbuild_work_state_analysis_prompt\b/, - message: 'missing product-domain Startchat prompt policy use', - }, - { - regex: /\bparse_work_state_analysis_response\b/, - message: 'missing product-domain Startchat response policy use', - }, { regex: /\bai_client\s*\.\s*send_message\b/, message: 'missing core-owned function-agent AI call', @@ -9634,18 +9775,10 @@ export const requiredContentRules = [ regex: /\bCoreCommitAiAnalysisService\b/, message: 'missing core-owned commit AI concrete service', }, - { - regex: /\bCoreWorkStateAiAnalysisService\b/, - message: 'missing core-owned Startchat AI concrete service', - }, { regex: /\bparse_commit_response_preserves_product_domain_response_policy\b/, message: 'missing Git function-agent AI response boundary regression test', }, - { - regex: /\bparse_complete_analysis_preserves_product_domain_response_policy\b/, - message: 'missing Startchat AI response boundary regression test', - }, ], }, { @@ -9661,14 +9794,6 @@ export const requiredContentRules = [ regex: /\bgit_commit_snapshot\b/, message: 'missing commit snapshot service method', }, - { - regex: /\bstartchat_git_snapshot\b/, - message: 'missing Startchat Git snapshot service method', - }, - { - regex: /\bstartchat_time_snapshot\b/, - message: 'missing Startchat time snapshot service method', - }, { regex: /\bGitService::get_status\b/, message: 'missing shared GitService status lookup', @@ -9681,41 +9806,17 @@ export const requiredContentRules = [ regex: /\bContextAnalyzer::analyze_project_context\b/, message: 'missing product-domain project context analysis', }, - { - regex: /\bprocess_manager::create_command\("git"\)/, - message: 'missing process-manager backed lenient Git command fallback', - }, - { - regex: /\bgit_unpushed_commits\b/, - message: 'missing unpushed-commit fallback helper', - }, - { - regex: /\bgit_ahead_behind\b/, - message: 'missing ahead/behind fallback helper', - }, - { - regex: /\bgit_last_commit_timestamp\b/, - message: 'missing last-commit timestamp helper', - }, ], }, { path: 'src/crates/services/services-integrations/tests/function_agent_contracts.rs', reason: - 'services-integrations function-agent Git service must preserve legacy Git snapshot behavior', + 'services-integrations function-agent Git service must preserve commit snapshot behavior', patterns: [ { regex: /\bgit_service_builds_commit_snapshot_from_staged_diff_without_unstaged_content\b/, message: 'missing staged/unstaged boundary regression test', }, - { - regex: /\bgit_service_startchat_snapshot_preserves_no_head_and_non_git_fallback\b/, - message: 'missing Startchat no-HEAD and non-Git fallback regression test', - }, - { - regex: /\bgit_service_time_snapshot_uses_last_commit_timestamp\b/, - message: 'missing time snapshot regression test', - }, ], }, { @@ -9729,17 +9830,6 @@ export const requiredContentRules = [ }, ], }, - { - path: 'src/crates/assembly/core/src/function_agents/startchat-func-agent/ai_service.rs', - reason: - 'legacy Startchat AI service path must remain a compatibility re-export only', - patterns: [ - { - regex: /\bCoreWorkStateAiAnalysisService as AIWorkStateService\b/, - message: 'missing Startchat AI service compatibility re-export', - }, - ], - }, { path: 'src/crates/assembly/core/src/function_agents/git-func-agent/commit_generator.rs', reason: @@ -9850,21 +9940,6 @@ export const requiredContentRules = [ }, ], }, - { - path: 'src/crates/assembly/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', - reason: - 'legacy Startchat work-state analyzer must delegate to the core product-domain runtime owner', - patterns: [ - { - regex: /\bCoreProductDomainRuntime\b/, - message: 'missing core product-domain runtime owner routing', - }, - { - regex: /\banalyze_function_agent_work_state\b/, - message: 'missing Startchat work-state owner method delegation', - }, - ], - }, { path: 'src/crates/contracts/product-domains/src/function_agents/ports.rs', reason: @@ -9878,22 +9953,6 @@ export const requiredContentRules = [ regex: /\bgenerate_commit_message\b/, message: 'missing function-agent commit facade orchestration', }, - { - regex: /\banalyze_work_state\b/, - message: 'missing function-agent work-state facade orchestration', - }, - { - regex: /\bgit_work_state_from_snapshot\b/, - message: 'missing Startchat Git snapshot projection helper', - }, - { - regex: /\bStartchatTimeSnapshot\b/, - message: 'missing Startchat time snapshot contract', - }, - { - regex: /\bstartchat_time_snapshot\b/, - message: 'missing Startchat time snapshot port', - }, ], }, { @@ -9911,41 +9970,6 @@ export const requiredContentRules = [ }, ], }, - { - path: 'src/crates/contracts/product-domains/src/function_agents/startchat_func_agent/utils.rs', - reason: - 'product-domains owns Startchat function-agent prompt and response policy while core keeps AI calls', - patterns: [ - { - regex: /\bpub const WORK_STATE_ANALYSIS_PROMPT\b/, - message: 'missing product-domain Startchat prompt template', - }, - { - regex: /\bpub fn build_work_state_analysis_prompt\b/, - message: 'missing product-domain Startchat prompt builder', - }, - { - regex: /\bpub struct ParsedCompleteAnalysis\b/, - message: 'missing Startchat complete-analysis parse result contract', - }, - { - regex: /\bpub fn parse_complete_analysis_value\b/, - message: 'missing Startchat complete-analysis value parser', - }, - { - regex: /\bpub fn parse_complete_analysis_json\b/, - message: 'missing Startchat complete-analysis JSON parser', - }, - { - regex: /\bpub fn parse_work_state_analysis_response\b/, - message: 'missing Startchat AI response policy', - }, - { - regex: /\bwork_state_ai_response_policy_extracts_json_and_maps_domain_errors\b/, - message: 'missing Startchat AI response policy regression test', - }, - ], - }, { path: 'src/crates/contracts/product-domains/src/function_agents/git_func_agent/utils.rs', reason: @@ -10130,21 +10154,13 @@ export const requiredContentRules = [ message: 'missing Git adapter delegation to integration runtime service', }, { - regex: /\bCoreCommitAiAnalysisService::new_with_agent_config\b/, + regex: /\bCoreCommitAiAnalysisService::new_with_task_config\b/, message: 'missing commit AI adapter delegation to concrete runtime service', }, - { - regex: /\bCoreWorkStateAiAnalysisService::new_with_agent_config\b/, - message: 'missing Startchat AI adapter delegation to concrete runtime service', - }, { regex: /\bgit_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate\b/, message: 'missing function-agent Git snapshot boundary regression test', }, - { - regex: /\bgit_adapter_startchat_snapshot_preserves_git_state_when_diff_has_no_head\b/, - message: 'missing Startchat Git diff fallback regression test', - }, ], }, { @@ -10176,10 +10192,6 @@ export const requiredContentRules = [ regex: /\bfn generate_function_agent_commit_message\b/, message: 'missing Git function-agent concrete runtime owner entrypoint', }, - { - regex: /\bfn analyze_function_agent_work_state\b/, - message: 'missing Startchat concrete runtime owner entrypoint', - }, { regex: /\bCoreFunctionAgentGitAdapter\b/, message: 'missing core-owned Git adapter binding', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index f2ce820c41..0344a9b1a8 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -7,6 +7,7 @@ export function runManifestParserSelfTest({ parseManifestDependencies, manifestDependencyMatches, matchingForbiddenDependency, + acpClosedFeatureProfileRules, coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, ownerCrateFeatureAssemblyRules, @@ -60,10 +61,13 @@ export function runManifestParserSelfTest({ const explicitTestManifest = [ '[package]', 'autotests = false', - ...agentRuntimeIntegrationTestTargets.flatMap(({ name, path }) => [ + ...agentRuntimeIntegrationTestTargets.flatMap(({ name, path, requiredFeatures }) => [ '[[test]]', `name = "${name}"`, `path = "${path}"`, + ...(requiredFeatures === undefined + ? [] + : [`required-features = [${requiredFeatures.map((feature) => `"${feature}"`).join(', ')}]`]), ]), '[lints]', ].join('\n'); @@ -152,6 +156,56 @@ export function runManifestParserSelfTest({ if (!orphanErrors.some((error) => error.includes('orphan_contracts.rs'))) { throw new Error('explicit integration-test topology must reject an orphan leaf test'); } + const reviewedLeafTargets = agentRuntimeIntegrationTestTargets.map((target) => ( + target.path === 'tests/agent_definition_contracts.rs' + ? { + ...target, + leaves: ['tests/agent_definition_contracts/prompt_contracts.rs'], + } + : target + )); + const missingReviewedLeafErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + leafRustFiles: [], + leafSources: new Map(), + }); + if (!missingReviewedLeafErrors.some((error) => error.includes('grouped test leaves'))) { + throw new Error('explicit integration-test topology must reject a removed reviewed leaf'); + } + for (const requiredFeaturesDeclaration of [ + 'required-features = [\n "opt-in",\n]', + '"required\\u002dfeatures" = ["opt-in"]', + ]) { + const unexpectedRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + manifestText: explicitTestManifest.replace( + 'required-features = ["agent-runtime"]', + requiredFeaturesDeclaration, + ), + }); + if (!unexpectedRequiredFeaturesErrors.some((error) => error.includes('required-features'))) { + throw new Error(`ungated explicit test topology accepted: ${requiredFeaturesDeclaration}`); + } + } + const independentRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets.map((target) => ( + target.path === 'tests/native_hook_execution_contracts.rs' + ? { ...target, requiredFeatures: ['native-hooks'] } + : target + )), + manifestText: explicitTestManifest.replace( + 'required-features = ["native-hook-runtime"]', + 'required-features = ["native-hooks"]', + ), + }); + if (independentRequiredFeaturesErrors.length > 0) { + throw new Error( + `target-scoped required-features contract rejected an independent target: ${independentRequiredFeaturesErrors.join('; ')}`, + ); + } const reviewedLeafCfgFixture = { ...explicitTestFixture, leafSources: new Map([[ @@ -316,6 +370,8 @@ export function runManifestParserSelfTest({ for (const featureName of [ 'agent-runtime', + 'diagnostics', + 'diff', 'announcement', 'canvas-runtime', 'debug-log', @@ -323,6 +379,7 @@ export function runManifestParserSelfTest({ 'file-watch', 'filesystem', 'git', + 'i18n-runtime', 'lsp', 'local-storage', 'process-runtime', @@ -352,11 +409,30 @@ export function runManifestParserSelfTest({ const coreManifest = 'src/crates/assembly/core/Cargo.toml'; const servicesCoreManifest = 'src/crates/services/services-core/Cargo.toml'; const expectedClosedCoreProfiles = [ + [coreManifest, 'default', []], + [coreManifest, 'i18n-runtime', ['dep:fluent-bundle', 'dep:unic-langid']], + [coreManifest, 'diagnostics', ['bitfun-services-core/diagnostics']], + [coreManifest, 'diff', ['bitfun-services-core/diff']], [servicesCoreManifest, 'default', []], + [servicesCoreManifest, 'diagnostics', ['dep:regex']], + [ + servicesCoreManifest, + 'diff', + ['dep:similar', 'dep:tokio', 'tokio/rt', 'tokio/time'], + ], [ servicesCoreManifest, 'filesystem', - ['dep:base64', 'dep:chrono', 'dep:ignore', 'dep:sha2', 'tokio/fs'], + [ + 'dep:base64', + 'dep:chrono', + 'dep:ignore', + 'dep:regex', + 'dep:sha2', + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + ], ], [ servicesCoreManifest, @@ -367,10 +443,15 @@ export function runManifestParserSelfTest({ 'dep:chrono', 'dep:fs2', 'dep:libc', + 'dep:regex', 'dep:sha2', + 'dep:similar', + 'dep:tokio', 'dep:windows', 'tokio/fs', + 'tokio/rt', 'tokio/sync', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_Storage_FileSystem', ], @@ -380,11 +461,14 @@ export function runManifestParserSelfTest({ 'process-runtime', [ 'dep:libc', + 'dep:tokio', 'dep:which', 'dep:win32job', 'dep:windows', 'tokio/io-util', 'tokio/process', + 'tokio/rt', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', 'windows/Win32_System_Threading', @@ -393,7 +477,20 @@ export function runManifestParserSelfTest({ [ servicesCoreManifest, 'workspace-instructions', - ['dep:globset', 'dep:serde_yaml', 'tokio/fs', 'tokio/io-util'], + [ + 'dep:globset', + 'dep:regex', + 'dep:serde_yaml', + 'dep:tokio', + 'tokio/fs', + 'tokio/io-util', + 'tokio/rt', + ], + ], + [ + servicesCoreManifest, + 'workspace-text-runtime', + ['dep:tokio', 'tokio/rt'], ], [ servicesCoreManifest, @@ -416,6 +513,8 @@ export function runManifestParserSelfTest({ 'dep:anyhow', 'dep:async-trait', 'dep:bitfun-runtime-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/workspace-ports', 'dep:dunce', 'process-runtime', 'tokio/fs', @@ -425,11 +524,19 @@ export function runManifestParserSelfTest({ ], [servicesCoreManifest, 'session-git', ['local-storage', 'dep:git2']], [servicesCoreManifest, 'workspace-identity', ['dep:dunce', 'dep:sha2']], - [coreManifest, 'dispatch-store', ['local-storage', 'bitfun-services-core/dispatch-workspace']], + [ + coreManifest, + 'dispatch-store', + ['dep:base64', 'local-storage', 'bitfun-services-core/dispatch-workspace'], + ], [coreManifest, 'filesystem', ['bitfun-services-core/filesystem']], - [coreManifest, 'local-storage', ['bitfun-services-core/local-storage']], + [ + coreManifest, + 'local-storage', + ['dep:bitfun-agent-tools', 'bitfun-services-core/local-storage'], + ], [coreManifest, 'process-runtime', ['bitfun-services-core/process-runtime']], - [coreManifest, 'lsp', ['dep:notify', 'bitfun-services-core/lsp']], + [coreManifest, 'lsp', ['dep:notify', 'bitfun-services-core/lsp', 'tokio/macros']], [coreManifest, 'terminal', ['dep:terminal-core']], [ coreManifest, @@ -492,6 +599,70 @@ export function runManifestParserSelfTest({ throw new Error(`core closed feature profile must not reach product-full in ${featureName}`); } } + const acpProfiles = new Map( + acpClosedFeatureProfileRules.map((rule) => [rule.featureName, rule]), + ); + const expectedAcpProfiles = new Map([ + ['default', ['client', 'server']], + [ + 'client', + [ + 'bitfun-agent-tools/acp-bridge', + 'dep:futures', + 'dep:serde', + // The bundled DeepSeek Harness profile the ACP client materializes: + // $DSH_HOME lookup, a minimum-version comparison, and packing the + // profile for a remote host. + 'dep:dirs', + 'dep:semver', + 'dep:tar', + 'dep:bitfun-core', + 'bitfun-core/agent-runtime', + 'bitfun-core/ssh-remote', + ], + ], + [ + 'server', + [ + 'dep:bitfun-agent-tools', + 'dep:bitfun-agent-runtime', + 'bitfun-agent-runtime/agent-runtime', + 'dep:bitfun-core-types', + 'dep:bitfun-core', + 'dep:sha2', + 'bitfun-core/agent-runtime', + 'bitfun-core/document-read', + 'bitfun-core/subscription-auth', + 'bitfun-core/deep-research', + 'bitfun-core/lsp', + 'bitfun-core/external-sources', + 'bitfun-core/tools-basic', + 'bitfun-core/tools-git', + 'bitfun-core/tools-mcp', + 'bitfun-core/tools-browser-web', + 'bitfun-core/tools-computer-use', + 'bitfun-core/tools-image-analysis', + 'bitfun-core/tools-miniapp', + 'bitfun-core/tools-canvas', + 'bitfun-core/tools-agent-control', + ], + ], + ]); + for (const [featureName, expectedReferences] of expectedAcpProfiles) { + const rule = acpProfiles.get(featureName); + if (!rule?.exact) { + throw new Error(`ACP closed feature profile must cover ${featureName} exactly`); + } + if ( + rule.requiredFeatureRefs.length !== expectedReferences.length + || expectedReferences.some((reference) => !rule.requiredFeatureRefs.includes(reference)) + ) { + throw new Error(`ACP closed feature profile has stale references for ${featureName}`); + } + if (rule.requiredFeatureRefs.some((reference) => reference.includes('product-full'))) { + throw new Error(`ACP closed feature profile must not reach product-full in ${featureName}`); + } + } const ownerFeatureRulePaths = new Set( ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), ); @@ -913,6 +1084,7 @@ export function runManifestParserSelfTest({ 'schannel', 'win32job', 'bitfun-relay-service', + 'bitfun-transport', 'htmd', 'legible', 'readability-js', @@ -931,6 +1103,7 @@ export function runManifestParserSelfTest({ 'aes', 'aes-gcm', 'bitfun-relay-service', + 'bitfun-transport', 'eventsource-stream', 'git2', 'glob', @@ -968,45 +1141,6 @@ export function runManifestParserSelfTest({ const servicesOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( (rule) => rule.crateName === 'services-integrations', ); - const workspaceReqwestRule = requiredContentRules.find((rule) => rule.path === 'Cargo.toml'); - const workspaceReqwestRuleText = workspaceReqwestRule?.patterns - .map((pattern) => pattern.regex.source) - .join('\n') ?? ''; - for (const featureName of ['http2', 'json', 'stream', 'multipart', 'query', 'form']) { - if (!workspaceReqwestRuleText.includes(featureName)) { - throw new Error(`workspace Reqwest boundary must allow only reviewed feature ${featureName}`); - } - } - const workspaceReqwestPattern = workspaceReqwestRule?.patterns[0]?.regex; - const reviewedReqwestDeclaration = - 'reqwest = { version = "0.13.4", default-features = false, features = ["http2", "json", "stream", "multipart", "query", "form"] }'; - if (!workspaceReqwestPattern?.test(reviewedReqwestDeclaration)) { - throw new Error('workspace Reqwest boundary must accept the reviewed transport/data profile'); - } - for (const featureName of ['default-tls', 'http3', '__native-tls']) { - const expandedDeclaration = reviewedReqwestDeclaration.replace( - '"form"]', - `"form", "${featureName}"]`, - ); - if (workspaceReqwestPattern.test(expandedDeclaration)) { - throw new Error(`workspace Reqwest boundary must reject TLS-enabling feature ${featureName}`); - } - } - for (const path of [ - 'src/apps/cli/Cargo.toml', - 'src/apps/desktop/Cargo.toml', - 'src/crates/adapters/ai-adapters/Cargo.toml', - 'src/crates/services/miniapp-market-service/Cargo.toml', - 'src/crates/services/skin-market-service/Cargo.toml', - ]) { - const reqwestRule = requiredContentRules.find((rule) => rule.path === path); - const reqwestRuleText = reqwestRule?.patterns - .map((pattern) => pattern.regex.source) - .join('\n') ?? ''; - if (!reqwestRuleText.includes('rustls')) { - throw new Error(`${path} must guard the explicit Reqwest Rustls client dependency`); - } - } const servicesCoreOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( (rule) => rule.crateName === 'services-core', ); @@ -1029,8 +1163,13 @@ export function runManifestParserSelfTest({ ['ignore', ['filesystem']], ['libc', ['local-storage', 'process-runtime']], ['notify', ['lsp']], + [ + 'regex', + ['diagnostics', 'filesystem', 'local-storage', 'markdown', 'workspace-instructions'], + ], ['rusqlite', ['permission']], ['serde_yaml', ['markdown', 'workspace-instructions']], + ['similar', ['diff', 'local-storage']], [ 'sha2', [ @@ -1045,6 +1184,21 @@ export function runManifestParserSelfTest({ ['win32job', ['process-runtime']], ['windows', ['json-io', 'local-storage', 'process-runtime']], ['zip', ['lsp']], + [ + 'tokio', + [ + 'diff', + 'filesystem', + 'json-io', + 'local-storage', + 'lsp', + 'permission', + 'process-runtime', + 'workspace-instructions', + 'workspace-runtime', + 'workspace-text-runtime', + ], + ], ]); for (const [dependencyName, ownerFeatures] of expectedServicesCoreOwners) { const dependency = servicesCoreOptionalOwnerRule?.dependencies.find( @@ -1320,6 +1474,9 @@ export function runManifestParserSelfTest({ const pluginPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/runtime-ports/src/plugin.rs', ); + const agentRuntimePublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/execution/agent-runtime/src/lib.rs', + ); const pluginRootReexportRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/runtime-ports/src/lib.rs', ); @@ -1415,6 +1572,20 @@ export function runManifestParserSelfTest({ ) { throw new Error('public API parser must collect top-level items and re-exports without impl methods'); } + const parsedAgentRuntimeModules = collectTopLevelRustPublicSymbols(` + #[cfg(feature = "agent-runtime")] + pub mod agents; + pub mod accidental_feature_free_api; + `); + if (!agentRuntimePublicApiRule?.allowedSymbols?.includes('agents')) { + throw new Error('Agent Runtime public API allowlist must include reviewed owner modules'); + } + if ( + !parsedAgentRuntimeModules.includes('accidental_feature_free_api') || + agentRuntimePublicApiRule.allowedSymbols.includes('accidental_feature_free_api') + ) { + throw new Error('Agent Runtime public API allowlist must reject an unreviewed feature-free module'); + } const parsedExternalSubagentIds = collectTopLevelRustPublicSymbols(` external_subagent_id!(ExternalSubagentLocalId, "local"); external_subagent_id!( @@ -2356,7 +2527,7 @@ export function runManifestParserSelfTest({ const requiredContentContracts = [ { - path: 'src/crates/contracts/runtime-ports/src/lib.rs', + path: 'src/crates/contracts/runtime-ports/src/agent_api.rs', contracts: [ 'AgentDialogTurnRequest', 'AgentDialogPrependedReminder', @@ -3258,7 +3429,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', contracts: [ 'product_assembly_plan_exposes_build_feature_groups_explicitly', 'product_runtime_assembly_reports_runtime_service_capability_gaps', @@ -3266,7 +3437,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', contracts: [ 'executable_plugin_runtime_is_limited_to_product_full_desktop_and_cli', 'executable_plugin_runtime_client_builds_agent_runtime_parts', @@ -3305,7 +3476,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', contracts: [ 'product_runtime_parts_can_build_agent_runtime_sdk_without_core', 'sdk_delivery_profile_builds_shared_runtime_owner_ceiling_without_bitfun_core', @@ -4239,7 +4410,7 @@ export function runManifestParserSelfTest({ contracts: ['renumber_research_report', 'ResearchCitationRenumberOutput', 'ResearchCitationDisplayMapEntry', 'rejected_index_rows_dropped', 'should_post_process_research_report'], }, { - path: 'src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/deep_research_contracts.rs', + path: 'src/crates/execution/agent-runtime/tests/deep_research_contracts.rs', contracts: ['deep_research_citation_renumber_owner_preserves_report_and_display_map_contracts', 'deep_research_citation_renumber_owner_is_idempotent_without_citations'], }, { @@ -4300,11 +4471,11 @@ export function runManifestParserSelfTest({ { path: 'src/crates/assembly/core/Cargo.toml', contracts: [ - 'bitfun-product-capabilities = \\{ path = "\\.\\.\\/product-capabilities", default-features = false, optional = true \\}', + 'bitfun-product-capabilities = \\{ path = "\\.\\.\\/product-capabilities", optional = true \\}', 'bitfun-ai-adapters = \\{ path = "\\.\\.\\/\\.\\.\\/adapters\\/ai-adapters", optional = true \\}', - 'bitfun-tool-packs = \\{ path = "\\.\\.\\/\\.\\.\\/execution\\/tool-provider-groups", default-features = false, optional = true \\}', - 'bitfun-services-integrations = \\{ path = "\\.\\.\\/\\.\\.\\/services\\/services-integrations", default-features = false, optional = true \\}', - 'bitfun-product-domains = \\{ path = "\\.\\.\\/\\.\\.\\/contracts\\/product-domains", default-features = false, optional = true \\}', + 'bitfun-tool-packs = \\{ path = "\\.\\.\\/\\.\\.\\/execution\\/tool-provider-groups", optional = true \\}', + 'bitfun-services-integrations = \\{ path = "\\.\\.\\/\\.\\.\\/services\\/services-integrations", optional = true \\}', + 'bitfun-product-domains = \\{ path = "\\.\\.\\/\\.\\.\\/contracts\\/product-domains", optional = true \\}', 'dep:bitfun-ai-adapters', 'ai-adapter-runtime', 'canvas-runtime', @@ -4875,14 +5046,11 @@ export function runManifestParserSelfTest({ contracts: [ 'prepare_commit_ai_prompt', 'parse_commit_ai_response', - 'build_work_state_analysis_prompt', - 'parse_work_state_analysis_response', 'send_message', 'AgentError::internal_error', 'CoreCommitAiAnalysisService', - 'CoreWorkStateAiAnalysisService', + 'CoreCommitAiAnalysisService::new_with_task_config', 'parse_commit_response_preserves_product_domain_response_policy', - 'parse_complete_analysis_preserves_product_domain_response_policy', ], }, { @@ -4890,64 +5058,33 @@ export function runManifestParserSelfTest({ contracts: [ 'FunctionAgentGitService', 'git_commit_snapshot', - 'startchat_git_snapshot', - 'startchat_time_snapshot', - 'process_manager::create_command("git")', - 'git_unpushed_commits', - 'git_ahead_behind', - 'git_last_commit_timestamp', ], }, { path: 'src/crates/services/services-integrations/tests/function_agent_contracts.rs', contracts: [ 'git_service_builds_commit_snapshot_from_staged_diff_without_unstaged_content', - 'git_service_startchat_snapshot_preserves_no_head_and_non_git_fallback', - 'git_service_time_snapshot_uses_last_commit_timestamp', ], }, { path: 'src/crates/assembly/core/src/function_agents/git-func-agent/ai_service.rs', contracts: ['CoreCommitAiAnalysisService as AIAnalysisService'], }, - { - path: 'src/crates/assembly/core/src/function_agents/startchat-func-agent/ai_service.rs', - contracts: ['CoreWorkStateAiAnalysisService as AIWorkStateService'], - }, { path: 'src/crates/assembly/core/src/function_agents/git-func-agent/commit_generator.rs', contracts: ['CoreProductDomainRuntime', 'generate_function_agent_commit_message'], }, - { - path: 'src/crates/assembly/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', - contracts: ['CoreProductDomainRuntime', 'analyze_function_agent_work_state'], - }, { path: 'src/crates/contracts/product-domains/src/function_agents/ports.rs', contracts: [ 'FunctionAgentRuntimeFacade', 'generate_commit_message', - 'analyze_work_state', - 'git_work_state_from_snapshot', - 'StartchatTimeSnapshot', - 'startchat_time_snapshot', ], }, { path: 'src/crates/contracts/product-domains/src/function_agents/common.rs', contracts: ['extract_json_from_ai_response', 'try_repair_json'], }, - { - path: 'src/crates/contracts/product-domains/src/function_agents/startchat_func_agent/utils.rs', - contracts: [ - 'WORK_STATE_ANALYSIS_PROMPT', - 'build_work_state_analysis_prompt', - 'ParsedCompleteAnalysis', - 'parse_complete_analysis_value', - 'parse_complete_analysis_json', - 'parse_work_state_analysis_response', - ], - }, { path: 'src/crates/contracts/product-domains/src/function_agents/git_func_agent/utils.rs', contracts: [ @@ -5451,7 +5588,6 @@ async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), Dis 'bitfun-sdk-host', 'bitfun-services-core', 'bitfun-services-integrations', - 'bitfun-transport', 'terminal-core', 'tool-runtime', 'tauri', @@ -5463,13 +5599,14 @@ async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), Dis throw new Error(`agent-runtime-ipc lightweight boundary must forbid ${dependency}`); } } - for (const sharedContract of [ + for (const allowedDependency of [ 'bitfun-events', 'bitfun-product-domains', 'bitfun-runtime-ports', + 'bitfun-transport', ]) { - if (runtimeIpcBoundary?.forbiddenDeps.includes(sharedContract)) { - throw new Error(`agent-runtime-ipc must be allowed to reuse ${sharedContract}`); + if (runtimeIpcBoundary?.forbiddenDeps.includes(allowedDependency)) { + throw new Error(`agent-runtime-ipc must be allowed to reuse ${allowedDependency}`); } } const runtimeIpcProfile = dependencyProfileRules.find( @@ -5555,4 +5692,52 @@ async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), Dis 'agent-runtime-ipc local-only guard must cover network transports in new private modules', ); } + const runtimeIpcSharedTransportPattern = runtimeIpcTransportRule?.patterns.find( + (pattern) => pattern.message.includes('bounded JSON API'), + )?.regex; + if ( + !runtimeIpcSharedTransportPattern || + !runtimeIpcSharedTransportPattern.test('use bitfun_transport::TransportAdapter;') || + !runtimeIpcSharedTransportPattern.test('use bitfun_transport as transport;') || + runtimeIpcSharedTransportPattern.test('use bitfun_transport::encode_json_with_limit;') || + runtimeIpcSharedTransportPattern.test('use bitfun_transport::JsonCodecError;') + ) { + throw new Error( + 'agent-runtime-ipc may consume only the reviewed bitfun-transport bounded JSON API', + ); + } + const runtimeIpcTransportFeatureRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/Cargo.toml' && + rule.reason.includes('bitfun-transport features'), + ); + const runtimeIpcTransportFeaturePattern = + runtimeIpcTransportFeatureRule?.patterns[0]?.regex; + if ( + !runtimeIpcTransportFeaturePattern?.test( + 'bitfun-transport = { path = "../transport", features = ["tauri-adapter"] }', + ) || + runtimeIpcTransportFeaturePattern.test( + 'bitfun-transport = { path = "../transport" }', + ) + ) { + throw new Error('agent-runtime-ipc must not enable bitfun-transport features'); + } + const runtimeIpcTransportDependencyRule = requiredContentRules.find( + (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/Cargo.toml' && + rule.reason.includes('exact feature-free bitfun-transport dependency'), + ); + const runtimeIpcTransportDependencyPattern = + runtimeIpcTransportDependencyRule?.patterns[0]?.regex; + if ( + !runtimeIpcTransportDependencyPattern?.test( + 'bitfun-transport = { path = "../transport" }', + ) || + runtimeIpcTransportDependencyPattern.test( + 'bitfun-transport = {\n path = "../transport",\n features = ["tauri-adapter"]\n}', + ) + ) { + throw new Error( + 'agent-runtime-ipc must keep the exact feature-free bitfun-transport dependency', + ); + } } diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 24bf28cf31..d2ab567a26 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -4,6 +4,8 @@ import { spawnSync } from 'child_process'; import { fileURLToPath } from 'url'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path'; import { + chmodSync, + copyFileSync, existsSync, mkdirSync, readFileSync, @@ -15,6 +17,7 @@ import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; +import { resolveReleaseChannel } from './release-channel.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -40,18 +43,28 @@ async function main() { const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig, member: 'desktop' }); Object.assign(process.env, productBuildEnvironment(resolution)); console.log(`[product] ${resolution.assembly.member} ${resolution.assembly.assemblyDigest}`); - - const flashgrepBinary = ensureFlashgrepBinary(); - process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; + const releaseChannel = resolveReleaseChannel(process.env.BITFUN_RELEASE_CHANNEL); + console.log(`[release] channel=${releaseChannel.channel}`); const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); + const flashgrepBinary = prepareMacOSFlashgrepForSigning( + ensureFlashgrepBinary(), + desktopDir, + ); + process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; + if (process.platform === 'darwin' && requestsDmgBundle(forward)) { + // Tauri otherwise passes --skip-jenkins under CI, which drops the branded + // Finder background and icon positions from the generated DMG. + process.env.TAURI_BUNDLER_DMG_IGNORE_CI = 'true'; + } const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, resolution, + releaseChannel, }); const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); const tauriArgs = ['build', '--config', tauriConfig, ...forward]; @@ -71,10 +84,6 @@ async function main() { process.exit(1); } - if (r.status === 0 && process.platform === 'darwin') { - patchDmgExtras(ROOT); - } - // Keep only the latest useful Cargo caches for this build profile after tauri build ends. try { const { profileFromTauriBuildArgs, runGcBestEffort, targetFromTauriBuildArgs } = await import( @@ -166,9 +175,49 @@ function optionValue(args, option) { return undefined; } +export function prepareMacOSFlashgrepForSigning( + flashgrepBinary, + desktopDir, + runtime = {}, +) { + const platform = runtime.platform ?? process.platform; + const signingIdentity = runtime.signingIdentity ?? process.env.APPLE_SIGNING_IDENTITY; + if (platform !== 'darwin' || !signingIdentity) { + return flashgrepBinary; + } + + const signedDir = join(desktopDir, 'gen', 'signed-resources', 'flashgrep'); + const signedBinary = join(signedDir, basename(flashgrepBinary)); + mkdirSync(signedDir, { recursive: true }); + copyFileSync(flashgrepBinary, signedBinary); + chmodSync(signedBinary, statSync(signedBinary).mode | 0o111); + + const run = runtime.spawnSync ?? spawnSync; + const result = run( + 'codesign', + [ + '--force', + '--sign', + signingIdentity, + '--options', + 'runtime', + '--timestamp', + signedBinary, + ], + { encoding: 'utf8', shell: false }, + ); + if (result.error || result.status !== 0) { + const detail = result.error?.message || result.stderr || `exit status ${result.status}`; + throw new Error(`Failed to sign bundled flashgrep binary: ${detail}`); + } + + console.log(`[tauri-build] Signed bundled flashgrep binary: ${signedBinary}`); + return signedBinary; +} + export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution } + { desktopDir, flashgrepBinary, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -180,6 +229,21 @@ export function prepareTauriConfig( config.identifier = resolution.assembly.bundleId; } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + // The DeepSeek bridge is not a compile-time resource: cargo check and + // desktop:dev must not require packages/dsh-acp/dist-profile. Official + // packaging injects it here; frontend:build-all (beforeBuildCommand) + // compiles the profile before Tauri copies resources. + injectDshProfileResource(config); + + const release = releaseChannel + ?? resolveReleaseChannel(process.env.BITFUN_RELEASE_CHANNEL); + const primaryEndpoint = + process.env.TAURI_UPDATER_ENDPOINT || release.primaryUpdaterEndpoint; + const fallbackEndpoint = + process.env.TAURI_UPDATER_FALLBACK_ENDPOINT || release.fallbackUpdaterEndpoint; + process.env.BITFUN_RELEASE_CHANNEL = release.channel; + process.env.BITFUN_UPDATER_PRIMARY_ENDPOINT = primaryEndpoint; + process.env.BITFUN_UPDATER_FALLBACK_ENDPOINT = fallbackEndpoint; const enabled = ['1', 'true', 'yes'].includes( String(process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS || '').toLowerCase() @@ -196,16 +260,9 @@ export function prepareTauriConfig( process.exit(1); } - const primaryEndpoint = - process.env.TAURI_UPDATER_ENDPOINT || - 'https://github.com/GCWing/BitFun/releases/latest/download/latest.json'; // Fallback endpoint used when GitHub is unreachable (not when no update is found). // Tauri updater iterates endpoints and only falls through on network/HTTP errors; // a 204 (no update) or a successfully parsed manifest stops the loop. - const fallbackEndpoint = - process.env.TAURI_UPDATER_FALLBACK_ENDPOINT || - 'https://openbitfun.com/release/latest.json'; - config.bundle = { ...(config.bundle || {}), createUpdaterArtifacts: true, @@ -221,7 +278,7 @@ export function prepareTauriConfig( }, }; console.log( - `[tauri-build] Updater artifacts enabled: ${primaryEndpoint} (fallback: ${fallbackEndpoint})` + `[tauri-build] Updater artifacts enabled for ${release.channel}: ${primaryEndpoint} (fallback: ${fallbackEndpoint})` ); } @@ -237,6 +294,18 @@ export function prepareTauriConfig( return generatedConfig; } +const DSH_PROFILE_RESOURCE_SOURCE = '../../../packages/dsh-acp/dist-profile'; +const DSH_PROFILE_RESOURCE_TARGET = 'resources/dsh-profile'; + +function injectDshProfileResource(config) { + const resources = { ...(config.bundle?.resources || {}) }; + resources[DSH_PROFILE_RESOURCE_SOURCE] = DSH_PROFILE_RESOURCE_TARGET; + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { const resources = { ...(config.bundle?.resources || {}) }; delete resources['../../../resources/flashgrep']; @@ -270,48 +339,6 @@ function toTauriPath(value) { return value.split(sep).join('/'); } -// Find all .dmg files under target/ and inject the helper TXT files -// (quarantine removal instructions) into each one. -function patchDmgExtras(root) { - const patchScript = join(root, 'scripts', 'patch-dmg-extras.sh'); - const targetDir = join(root, 'target'); - - const dmgFiles = findDmgFiles(targetDir); - if (dmgFiles.length === 0) { - console.log('[patch-dmg] No .dmg files found — skipping.'); - return; - } - - for (const dmg of dmgFiles) { - console.log(`[patch-dmg] Patching ${dmg}`); - const p = spawnSync('bash', [patchScript, dmg], { - stdio: 'inherit', - shell: false, - }); - if (p.status !== 0) { - console.error(`[patch-dmg] Failed to patch ${dmg}`); - process.exit(1); - } - } -} - -function findDmgFiles(dir) { - const results = []; - try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...findDmgFiles(full)); - } else if (entry.name.endsWith('.dmg')) { - results.push(full); - } - } - } catch { - // directory may not exist for some targets - } - return results; -} - if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch((e) => { console.error(e); diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 4f15bc11bd..1dd4350ac6 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -3,13 +3,109 @@ import { mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { prepareTauriConfig, shouldRetryMacDmgBuild } from './desktop-tauri-build.mjs'; +import { + prepareMacOSFlashgrepForSigning, + prepareTauriConfig, + shouldRetryMacDmgBuild, +} from './desktop-tauri-build.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; const FAILED_BUILD = { status: 1 }; const DMG_ARGS = ['--target', 'x86_64-apple-darwin', '--bundles', 'app,dmg']; const ROOT = join(import.meta.dirname, '..'); +test('release builds do not mutate DMGs after Tauri signs and notarizes them', () => { + const source = readFileSync(join(ROOT, 'scripts', 'desktop-tauri-build.mjs'), 'utf8'); + assert.doesNotMatch(source, /patchDmgExtras/); + assert.doesNotMatch(source, /patch-dmg-extras\.sh/); + assert.match(source, /TAURI_BUNDLER_DMG_IGNORE_CI = 'true'/); +}); + +test('Desktop DMG uses the branded installer layout', () => { + const config = JSON.parse( + readFileSync(join(ROOT, 'src', 'apps', 'desktop', 'tauri.conf.json'), 'utf8') + ); + assert.deepEqual(config.bundle.macOS.dmg, { + background: 'dmg/background.png', + windowSize: { width: 660, height: 400 }, + appPosition: { x: 180, y: 170 }, + applicationFolderPosition: { x: 480, y: 170 }, + }); +}); + +test('macOS release signing covers the bundled flashgrep executable', () => { + const fixture = join(tmpdir(), `bitfun-flashgrep-signing-${process.pid}-${Date.now()}`); + const desktopDir = join(fixture, 'src', 'apps', 'desktop'); + const source = join(fixture, 'flashgrep-aarch64-apple-darwin'); + const calls = []; + mkdirSync(desktopDir, { recursive: true }); + writeFileSync(source, 'test-binary'); + + try { + const signed = prepareMacOSFlashgrepForSigning(source, desktopDir, { + platform: 'darwin', + signingIdentity: 'Developer ID Application: Test (TEAMID)', + spawnSync: (...args) => { + calls.push(args); + return { status: 0 }; + }, + }); + + assert.notEqual(signed, source); + assert.equal(readFileSync(signed, 'utf8'), 'test-binary'); + assert.deepEqual(calls[0][0], 'codesign'); + assert.deepEqual(calls[0][1], [ + '--force', + '--sign', + 'Developer ID Application: Test (TEAMID)', + '--options', + 'runtime', + '--timestamp', + signed, + ]); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); + +test('unsigned and non-macOS builds keep the original flashgrep executable', () => { + assert.equal( + prepareMacOSFlashgrepForSigning('/tmp/flashgrep', '/tmp/desktop', { + platform: 'darwin', + signingIdentity: '', + }), + '/tmp/flashgrep', + ); + assert.equal( + prepareMacOSFlashgrepForSigning('/tmp/flashgrep', '/tmp/desktop', { + platform: 'linux', + signingIdentity: 'unused', + }), + '/tmp/flashgrep', + ); +}); + +test('macOS packaging fails when bundled flashgrep signing fails', () => { + const fixture = join(tmpdir(), `bitfun-flashgrep-signing-failure-${process.pid}-${Date.now()}`); + const desktopDir = join(fixture, 'src', 'apps', 'desktop'); + const source = join(fixture, 'flashgrep-x86_64-apple-darwin'); + mkdirSync(desktopDir, { recursive: true }); + writeFileSync(source, 'test-binary'); + + try { + assert.throws( + () => prepareMacOSFlashgrepForSigning(source, desktopDir, { + platform: 'darwin', + signingIdentity: 'Developer ID Application: Test (TEAMID)', + spawnSync: () => ({ status: 1, stderr: 'identity unavailable' }), + }), + /Failed to sign bundled flashgrep binary: identity unavailable/, + ); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); + function retryFixture() { const root = join(tmpdir(), `bitfun-dmg-retry-${process.pid}-${Date.now()}`); const desktopDir = join(root, 'src', 'apps', 'desktop'); @@ -130,7 +226,12 @@ test('Windows updater installs NSIS packages without showing its progress window const baseConfig = join(fixture, 'tauri.conf.json'); const updaterEnv = { BITFUN_ENABLE_UPDATER_ARTIFACTS: process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS, + BITFUN_RELEASE_CHANNEL: process.env.BITFUN_RELEASE_CHANNEL, + BITFUN_UPDATER_FALLBACK_ENDPOINT: process.env.BITFUN_UPDATER_FALLBACK_ENDPOINT, + BITFUN_UPDATER_PRIMARY_ENDPOINT: process.env.BITFUN_UPDATER_PRIMARY_ENDPOINT, TAURI_SIGNING_PRIVATE_KEY: process.env.TAURI_SIGNING_PRIVATE_KEY, + TAURI_UPDATER_ENDPOINT: process.env.TAURI_UPDATER_ENDPOINT, + TAURI_UPDATER_FALLBACK_ENDPOINT: process.env.TAURI_UPDATER_FALLBACK_ENDPOINT, TAURI_UPDATER_PUBKEY: process.env.TAURI_UPDATER_PUBKEY, }; mkdirSync(fixture, { recursive: true }); @@ -146,6 +247,7 @@ test('Windows updater installs NSIS packages without showing its progress window }); const config = JSON.parse(readFileSync(generated, 'utf8')); assert.equal(config.plugins.updater.windows.installMode, 'quiet'); + assert.match(config.plugins.updater.endpoints[0], /releases\/latest\/download/); } finally { for (const [name, value] of Object.entries(updaterEnv)) { if (value === undefined) { @@ -158,6 +260,99 @@ test('Windows updater installs NSIS packages without showing its progress window } }); +test('beta Desktop artifacts compile and bundle only beta updater endpoints', () => { + const fixture = join(tmpdir(), `bitfun-tauri-beta-${process.pid}-${Date.now()}`); + const baseConfig = join(fixture, 'tauri.conf.json'); + const names = [ + 'BITFUN_ENABLE_UPDATER_ARTIFACTS', + 'BITFUN_RELEASE_CHANNEL', + 'BITFUN_UPDATER_FALLBACK_ENDPOINT', + 'BITFUN_UPDATER_PRIMARY_ENDPOINT', + 'TAURI_SIGNING_PRIVATE_KEY', + 'TAURI_UPDATER_ENDPOINT', + 'TAURI_UPDATER_FALLBACK_ENDPOINT', + 'TAURI_UPDATER_PUBKEY', + ]; + const previous = Object.fromEntries(names.map((name) => [name, process.env[name]])); + mkdirSync(fixture, { recursive: true }); + writeFileSync(baseConfig, JSON.stringify({ bundle: { resources: {} } })); + process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS = 'true'; + process.env.BITFUN_RELEASE_CHANNEL = 'beta'; + process.env.TAURI_SIGNING_PRIVATE_KEY = 'test-private-key'; + process.env.TAURI_UPDATER_PUBKEY = 'test-public-key'; + delete process.env.TAURI_UPDATER_ENDPOINT; + delete process.env.TAURI_UPDATER_FALLBACK_ENDPOINT; + + try { + const generated = prepareTauriConfig(baseConfig, { + desktopDir: fixture, + flashgrepBinary: join(fixture, 'flashgrep'), + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.equal( + config.plugins.updater.endpoints[0], + 'https://github.com/GCWing/BitFun/releases/download/channel-beta/latest.json', + ); + assert.equal( + config.plugins.updater.endpoints[1], + 'https://openbitfun.com/release/beta/latest.json', + ); + assert.equal( + process.env.BITFUN_UPDATER_PRIMARY_ENDPOINT, + config.plugins.updater.endpoints[0], + ); + assert.equal( + process.env.BITFUN_UPDATER_FALLBACK_ENDPOINT, + config.plugins.updater.endpoints[1], + ); + } finally { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(fixture, { force: true, recursive: true }); + } +}); + +test('static desktop Tauri configs do not require the DeepSeek profile at compile time', () => { + for (const name of ['tauri.conf.json', 'tauri.dev.conf.json']) { + const config = JSON.parse( + readFileSync(join(ROOT, 'src', 'apps', 'desktop', name), 'utf8') + ); + assert.equal( + config.bundle.resources['../../../packages/dsh-acp/dist-profile'], + undefined, + `${name} must not list dist-profile as a compile-time resource`, + ); + } +}); + +test('official packaging injects the DeepSeek profile resource', () => { + const fixture = join(tmpdir(), `bitfun-tauri-dsh-${process.pid}-${Date.now()}`); + mkdirSync(fixture, { recursive: true }); + const baseConfig = join(fixture, 'tauri.conf.json'); + writeFileSync(baseConfig, JSON.stringify({ + bundle: { resources: { 'resources/worker_host.js': 'resources/worker_host.js' } }, + })); + try { + const generated = prepareTauriConfig(baseConfig, { + desktopDir: fixture, + flashgrepBinary: join(fixture, 'flashgrep'), + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.equal( + config.bundle.resources['../../../packages/dsh-acp/dist-profile'], + 'resources/dsh-profile', + ); + assert.equal( + config.bundle.resources['resources/worker_host.js'], + 'resources/worker_host.js', + ); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); + test('Desktop release config bundles models.dev notices and provenance', () => { const config = JSON.parse( readFileSync(join(ROOT, 'src', 'apps', 'desktop', 'tauri.conf.json'), 'utf8') diff --git a/scripts/dev.cjs b/scripts/dev.cjs index d218cb06b4..07cf156307 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -649,7 +649,9 @@ async function main() { // Step 1: Run all independent preparation tasks in parallel. // copy-monaco / generate-version / mobile-web / flashgrep have no // dependencies on each other; each task's output is line-prefixed so the - // interleaved logs stay attributable. + // interleaved logs stay attributable. The DeepSeek bridge is not prepared + // here: it is not a compile-time Tauri resource. Official desktop:build + // compiles it; local DeepSeek sessions run `pnpm run prepare:dsh-profile`. printStep( currentStep++, totalSteps, diff --git a/scripts/diagnostics/analyze-flowchat-log.mjs b/scripts/diagnostics/analyze-flowchat-log.mjs index 5cc894753c..081250c44b 100644 --- a/scripts/diagnostics/analyze-flowchat-log.mjs +++ b/scripts/diagnostics/analyze-flowchat-log.mjs @@ -1,16 +1,75 @@ +/** + * Read `flowchat.log` and answer the questions the viewport trail exists for. + * + * The log is JSONL, one object per entry, written by + * `src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts` behind + * `app.logging.flow_chat_diagnostics`. Viewport entries carry the tag + * `viewport`; history paging carries `history-paging`. + * + * The reports here are not a frequency count with extra steps. Each one + * corresponds to a fault that has actually shipped: + * + * - **Placements that did not stick.** A Turn that lands and is dragged away + * and a Turn that never landed leave the same final position, so every + * placement is recorded with what became of it and the drift is what + * separates them. + * - **Fights.** Travel far exceeding net displacement is two writers undoing + * each other — the shape of a snap back reissued 958 times without arriving. + * - **Refusals.** Who was outranked, by whom. A write that never happened is + * invisible in the DOM and in every other log. + * - **Silent declines.** Each writer's reason for not moving. "Nothing + * happened" has been the report more often than "it moved wrongly". + * + * Entries carry a `repeated` summary when the frontend coalesced a run of + * identical events, so every count here weighs an entry by what it stands for + * rather than by one. Ignoring that would under-report exactly the runaway + * loops the log is for. + */ + import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +/** Joins the parts of a composite map key; cannot occur inside one. */ +const KEY_SEPARATOR = String.fromCharCode(31); + +const VIEWPORT_TAG = 'viewport'; +const WRITE_LOCATION = 'viewportOwner.write'; +const OUTCOME_SUFFIX = '.outcome'; +const DROPPED_ENTRY_LOCATION = 'FlowChatDiagnosticsRecorder.flush'; + +/** Locations whose whole purpose is to record a movement that did not happen. */ +const DECLINE_LOCATIONS = new Set([ + 'anchor.dropped', + 'anchor.stoodDown', + 'followOutput.deferNewTurn', + 'historyPaging.refused', + 'snapBack.declined', + 'snapBack.notNeeded', + 'turnNavigation.rejected', +]); + +export const DEFAULT_OPTIONS = { + top: 20, + minDrift: 8, + gapMs: 750, + around: null, + radius: 12, + tags: [], +}; function printUsage() { console.log(`Usage: node scripts/diagnostics/analyze-flowchat-log.mjs [options] Options: - --top Maximum rows per summary table (default: 20) - --min-delta Minimum positive reservation jump to show (default: 100) - --around Show a compact event window around a sequence - --radius Sequence radius for --around (default: 8) - --help Show this help`); + --top Maximum rows per table (default: ${DEFAULT_OPTIONS.top}) + --min-drift Report a placement as unstuck past this drift (default: ${DEFAULT_OPTIONS.minDrift}) + --gap Quiet period that ends an episode (default: ${DEFAULT_OPTIONS.gapMs}) + --around Print the raw entries around a sequence number + --radius Sequence radius for --around (default: ${DEFAULT_OPTIONS.radius}) + --tag Only entries with this hypothesis tag; repeatable + --help Show this help`); } function parseNumberOption(args, index, optionName) { @@ -22,7 +81,11 @@ function parseNumberOption(args, index, optionName) { return value; } -function parseArgs(argv) { +export function parseArgs(rawArgv) { + // `npm run … -- ` forwards the separator itself; pnpm forwards it too + // when it is typed. Either way it is not an argument. + const argv = rawArgv[0] === '--' ? rawArgv.slice(1) : rawArgv; + if (argv.includes('--help')) { printUsage(); process.exit(0); @@ -34,21 +97,18 @@ function parseArgs(argv) { throw new Error('A FlowChat JSONL log path is required'); } - const options = { - logPath, - top: 20, - minDelta: 100, - around: null, - radius: 8, - }; + const options = { ...DEFAULT_OPTIONS, tags: [], logPath }; for (let index = 1; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--top') { options.top = Math.max(1, Math.floor(parseNumberOption(argv, index, arg))); index += 1; - } else if (arg === '--min-delta') { - options.minDelta = Math.max(0, parseNumberOption(argv, index, arg)); + } else if (arg === '--min-drift') { + options.minDrift = Math.max(0, parseNumberOption(argv, index, arg)); + index += 1; + } else if (arg === '--gap') { + options.gapMs = Math.max(0, parseNumberOption(argv, index, arg)); index += 1; } else if (arg === '--around') { options.around = Math.floor(parseNumberOption(argv, index, arg)); @@ -56,6 +116,11 @@ function parseArgs(argv) { } else if (arg === '--radius') { options.radius = Math.max(0, Math.floor(parseNumberOption(argv, index, arg))); index += 1; + } else if (arg === '--tag') { + const tag = argv[index + 1]; + if (!tag || tag.startsWith('--')) throw new Error('--tag requires a name'); + options.tags.push(tag); + index += 1; } else { throw new Error(`Unknown option: ${arg}`); } @@ -70,11 +135,26 @@ function finiteNumber(value) { } function round(value) { - return Math.round(finiteNumber(value) * 100) / 100; + return Math.round(finiteNumber(value) * 10) / 10; } -function reservationTotal(reservation) { - return finiteNumber(reservation?.collapse?.px) + finiteNumber(reservation?.pin?.px); +/** + * What one entry stands for. + * + * A coalesced entry is one line describing a run, so counting it as one event + * would report a 300-write fight as a handful of writes. Travel is the same + * question for distance. + */ +export function entryWeight(entry) { + return 1 + Math.max(0, Math.floor(finiteNumber(entry?.data?.repeated?.suppressedCount))); +} + +export function entryTravelPx(entry) { + const data = entry?.data ?? {}; + const own = data.toPx === undefined || data.fromPx === undefined + ? 0 + : Math.abs(finiteNumber(data.toPx) - finiteNumber(data.fromPx)); + return own + Math.abs(finiteNumber(data.repeated?.suppressedTravelPx)); } function compactData(data) { @@ -83,131 +163,330 @@ function compactData(data) { return serialized.length <= 240 ? serialized : `${serialized.slice(0, 237)}...`; } -async function analyze(options) { - const eventCounts = new Map(); - const reservationJumps = []; - const collapseIntents = []; - const sequenceWindow = []; - let lineCount = 0; - let eventCount = 0; - let parseErrorCount = 0; - - const input = createReadStream(options.logPath, { encoding: 'utf8' }); - const lines = createInterface({ input, crlfDelay: Infinity }); +function increment(map, key, by = 1) { + map.set(key, (map.get(key) ?? 0) + by); +} - for await (const line of lines) { - lineCount += 1; - if (!line.trim()) continue; +/** + * Group viewport writes into stretches of activity. + * + * A fault is a period, not an entry: "it flickered for a second when I opened + * the session" is one episode with several owners in it. The gap that ends one + * is wall-clock, because the interesting silence is the reader looking at a + * still transcript. + */ +export function collectEpisodes(writes, gapMs) { + const episodes = []; + let current = null; - let event; - try { - event = JSON.parse(line); - } catch { - parseErrorCount += 1; - continue; + for (const write of writes) { + const atMs = finiteNumber(write.performanceTimeMs); + if (current === null || atMs - current.endMs > gapMs) { + current = { + firstSequence: write.sequence, + lastSequence: write.sequence, + startMs: atMs, + endMs: atMs, + writes: 0, + refusals: 0, + travelPx: 0, + fromPx: null, + toPx: null, + owners: new Map(), + }; + episodes.push(current); } - eventCount += 1; - const countKey = `${event.location ?? ''}\u0000${event.message ?? ''}`; - const existingCount = eventCounts.get(countKey); - if (existingCount) { - existingCount.count += 1; + const weight = entryWeight(write); + const granted = write.data?.granted !== false; + current.lastSequence = write.sequence; + current.endMs = atMs; + current.travelPx += entryTravelPx(write); + if (granted) { + current.writes += weight; + if (current.fromPx === null) current.fromPx = finiteNumber(write.data?.fromPx); + current.toPx = finiteNumber(write.data?.toPx); } else { - eventCounts.set(countKey, { - count: 1, - location: event.location ?? '', - message: event.message ?? '', - }); + current.refusals += weight; } + increment(current.owners, String(write.data?.owner ?? 'unknown'), weight); + } + + return episodes.map(episode => { + const netPx = episode.fromPx === null ? 0 : episode.toPx - episode.fromPx; + return { + sequences: `${episode.firstSequence}-${episode.lastSequence}`, + durationMs: Math.round(episode.endMs - episode.startMs), + writes: episode.writes, + refusals: episode.refusals, + travelPx: round(episode.travelPx), + netPx: round(netPx), + /* + * Distance travelled per pixel of progress. One means a clean move; + * anything large is writers undoing each other, and it is the number to + * sort by when the report is "it shook". + */ + churn: Math.abs(netPx) < 1 + ? round(episode.travelPx) + : round(episode.travelPx / Math.abs(netPx)), + owners: [...episode.owners.entries()] + .sort((left, right) => right[1] - left[1]) + .map(([owner, count]) => `${owner}x${count}`) + .join(' '), + }; + }); +} - if ( - event.location === 'VirtualMessageList.updateBottomReservationState' && - event.data?.before && - event.data?.after - ) { - const before = reservationTotal(event.data.before); - const after = reservationTotal(event.data.after); - const delta = after - before; - if (delta >= options.minDelta) { - reservationJumps.push({ - sequence: event.sequence, - deltaPx: round(delta), - beforePx: round(before), - afterPx: round(after), - collapsePx: round(event.data.after.collapse?.px), - pinPx: round(event.data.after.pin?.px), - coordinatorMode: event.data.coordinatorMode ?? '', - following: event.data.isFollowingOutput === true, - streaming: event.data.isStreamingOutput === true, - }); +/** + * Pair every placement with the sample taken after it settled. + * + * Matched first-in-first-out per location: an outcome lands hundreds of + * milliseconds after its placement, so another placement of the same kind can + * begin in between, and the queue keeps them in order. An outcome with nothing + * pending is reported rather than dropped — it means the log starts mid-flight. + */ +export function joinPlacements(entries) { + const pending = new Map(); + const placements = []; + const orphanedOutcomes = []; + + for (const entry of entries) { + const location = String(entry.location ?? ''); + if (location.endsWith(OUTCOME_SUFFIX)) { + const placedLocation = location.slice(0, -OUTCOME_SUFFIX.length); + const queue = pending.get(placedLocation); + const placement = queue?.shift(); + if (!placement) { + orphanedOutcomes.push({ sequence: entry.sequence, location: placedLocation }); + continue; } + placement.outcome = entry; + continue; } + if (entry.data?.placedPx === undefined) continue; + + const placement = { placed: entry, outcome: null }; + placements.push(placement); + const queue = pending.get(location); + if (queue) queue.push(placement); + else pending.set(location, [placement]); + } + + return { placements, orphanedOutcomes }; +} + +export function summarizePlacements(placements) { + return placements.map(({ placed, outcome }) => { + const data = placed.data ?? {}; + const outcomeData = outcome?.data ?? {}; + return { + sequence: placed.sequence, + location: String(placed.location ?? ''), + branch: String(data.branch ?? data.reason ?? ''), + beforePx: round(data.beforePx), + placedPx: round(data.placedPx), + targetPx: data.targetPx === undefined ? round(data.placedPx) : round(data.targetPx), + settledPx: outcome ? round(outcomeData.settledPx) : null, + driftPx: outcome ? round(outcomeData.driftPx) : null, + /* + * No outcome is not "no drift". The sample is scheduled on a timer, so a + * placement at the end of the log, or one whose view unmounted first, + * never reports — and reading that as a clean landing is how a report + * turns into a false negative. + */ + settled: outcome ? 'yes' : 'unknown', + }; + }); +} - if ( - event.location === 'VirtualMessageList.handleToolCardCollapseIntent' && - event.message === 'Tool card collapse reservation calculated' - ) { - const current = finiteNumber(event.data?.currentTotalCompensationPx); - const provisional = finiteNumber(event.data?.provisionalTotalCompensationPx); - collapseIntents.push({ - sequence: event.sequence, - tool: event.data?.nextIntent?.toolName ?? '', - cardHeightPx: round(event.data?.estimatedShrink), - distancePx: round(event.data?.effectiveDistanceFromBottom), - addedPx: round(provisional - current), - totalPx: round(provisional), - coordinatorMode: event.data?.coordinatorMode ?? '', - }); +export function analyzeEntries(entries, options) { + const settings = { ...DEFAULT_OPTIONS, ...options }; + const tags = new Set(settings.tags ?? []); + const kept = tags.size === 0 + ? entries + : entries.filter(entry => tags.has(String(entry.hypothesis ?? ''))); + + const viewportEntries = kept.filter(entry => entry.hypothesis === VIEWPORT_TAG); + const writes = viewportEntries.filter(entry => entry.location === WRITE_LOCATION); + + const refusals = new Map(); + const ownerActivity = new Map(); + for (const write of writes) { + const weight = entryWeight(write); + const owner = String(write.data?.owner ?? 'unknown'); + const activity = ownerActivity.get(owner) ?? { owner, writes: 0, refusals: 0, travelPx: 0 }; + activity.travelPx += entryTravelPx(write); + if (write.data?.granted === false) { + activity.refusals += weight; + increment(refusals, `${owner}${KEY_SEPARATOR}${String(write.data?.heldBy ?? 'nobody')}`, weight); + } else { + activity.writes += weight; } + ownerActivity.set(owner, activity); + } + + const declines = new Map(); + for (const entry of viewportEntries) { + const location = String(entry.location ?? ''); + if (!DECLINE_LOCATIONS.has(location)) continue; + const reason = String(entry.data?.reason ?? entry.data?.branch ?? ''); + increment(declines, `${location}${KEY_SEPARATOR}${reason}`, entryWeight(entry)); + } + + const frequency = new Map(); + for (const entry of kept) { + const key = `${String(entry.hypothesis ?? '')}${KEY_SEPARATOR}${String(entry.location ?? '')}`; + increment(frequency, key, entryWeight(entry)); + } + + const { placements, orphanedOutcomes } = joinPlacements(viewportEntries); + const summarized = summarizePlacements(placements); - if ( - options.around !== null && - finiteNumber(event.sequence) >= options.around - options.radius && - finiteNumber(event.sequence) <= options.around + options.radius - ) { - sequenceWindow.push({ - sequence: event.sequence, - location: event.location ?? '', - message: event.message ?? '', - data: compactData(event.data), - }); + const droppedEntries = kept + .filter(entry => entry.location === DROPPED_ENTRY_LOCATION) + .reduce((total, entry) => total + finiteNumber(entry.data?.droppedEntries), 0); + + return { + entryCount: kept.length, + viewportEntryCount: viewportEntries.length, + droppedEntries, + sequenceRange: kept.length === 0 + ? null + : { first: kept[0].sequence, last: kept[kept.length - 1].sequence }, + episodes: collectEpisodes(writes, settings.gapMs) + .sort((left, right) => right.churn - left.churn), + unstuckPlacements: summarized + .filter(placement => placement.driftPx !== null + && Math.abs(placement.driftPx) >= settings.minDrift) + .sort((left, right) => Math.abs(right.driftPx) - Math.abs(left.driftPx)), + unsampledPlacements: summarized.filter(placement => placement.settled === 'unknown'), + placementCount: summarized.length, + orphanedOutcomes, + refusals: [...refusals.entries()] + .map(([key, count]) => { + const [owner, heldBy] = key.split(KEY_SEPARATOR); + return { owner, refusedBy: heldBy, count }; + }) + .sort((left, right) => right.count - left.count), + ownerActivity: [...ownerActivity.values()] + .map(activity => ({ ...activity, travelPx: round(activity.travelPx) })) + .sort((left, right) => right.writes + right.refusals - (left.writes + left.refusals)), + declines: [...declines.entries()] + .map(([key, count]) => { + const [location, reason] = key.split(KEY_SEPARATOR); + return { location, reason, count }; + }) + .sort((left, right) => right.count - left.count), + frequency: [...frequency.entries()] + .map(([key, count]) => { + const [tag, location] = key.split(KEY_SEPARATOR); + return { tag, location, count }; + }) + .sort((left, right) => right.count - left.count), + window: settings.around === null ? [] : kept + .filter(entry => finiteNumber(entry.sequence) >= settings.around - settings.radius + && finiteNumber(entry.sequence) <= settings.around + settings.radius) + .map(entry => ({ + sequence: entry.sequence, + tag: String(entry.hypothesis ?? ''), + location: String(entry.location ?? ''), + message: String(entry.message ?? ''), + data: compactData(entry.data), + })), + }; +} + +export async function readEntries(logPath) { + const entries = []; + let lineCount = 0; + let parseErrorCount = 0; + + const input = createReadStream(logPath, { encoding: 'utf8' }); + const lines = createInterface({ input, crlfDelay: Infinity }); + for await (const line of lines) { + lineCount += 1; + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line)); + } catch { + parseErrorCount += 1; } } - console.log(`FlowChat log: ${options.logPath}`); - console.log(`Lines: ${lineCount}, events: ${eventCount}, parse errors: ${parseErrorCount}`); + return { entries, lineCount, parseErrorCount }; +} - console.log('\nMost frequent events'); - console.table( - [...eventCounts.values()] - .sort((left, right) => right.count - left.count) - .slice(0, options.top), +function reportTable(title, rows, top) { + console.log(`\n${title}`); + if (rows.length === 0) { + console.log(' (none)'); + return; + } + console.table(rows.slice(0, top)); + if (rows.length > top) { + console.log(` ... ${rows.length - top} more`); + } +} + +export function printReport(report, options, source) { + console.log(`FlowChat log: ${source.logPath}`); + console.log( + `Lines: ${source.lineCount}, entries: ${report.entryCount}` + + ` (viewport: ${report.viewportEntryCount}), parse errors: ${source.parseErrorCount}`, ); + if (report.sequenceRange) { + console.log(`Sequences: ${report.sequenceRange.first}-${report.sequenceRange.last}`); + } + if (report.droppedEntries > 0) { + // Said loudly: every count below is a lower bound once entries were lost. + console.log( + `WARNING: ${report.droppedEntries} entries were dropped before reaching the log.` + + ' Counts below are lower bounds.', + ); + } - console.log(`\nLargest reservation increases (>= ${options.minDelta}px)`); - console.table( - reservationJumps - .sort((left, right) => right.deltaPx - left.deltaPx) - .slice(0, options.top), + reportTable( + 'Episodes of viewport activity, worst churn first (travel per pixel of progress)', + report.episodes, + options.top, ); + reportTable( + `Placements that did not stick (drift >= ${options.minDrift}px)`, + report.unstuckPlacements, + options.top, + ); + reportTable('Refusals: who was outranked, by whom', report.refusals, options.top); + reportTable('Declines: a writer choosing not to move, and why', report.declines, options.top); + reportTable('Per owner', report.ownerActivity, options.top); + reportTable('Most frequent locations', report.frequency, options.top); - console.log('\nLargest collapse-intent estimates'); - console.table( - collapseIntents - .sort((left, right) => right.addedPx - left.addedPx) - .slice(0, options.top), + console.log( + `\nPlacements: ${report.placementCount},` + + ` never sampled: ${report.unsampledPlacements.length},` + + ` outcomes with no placement in this log: ${report.orphanedOutcomes.length}`, ); if (options.around !== null) { - console.log(`\nEvents around sequence ${options.around} (+/- ${options.radius})`); - console.table(sequenceWindow); + reportTable( + `Entries around sequence ${options.around} (+/- ${options.radius})`, + report.window, + report.window.length, + ); } } -try { +async function main() { const options = parseArgs(process.argv.slice(2)); - await analyze(options); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; + const source = await readEntries(options.logPath); + const report = analyzeEntries(source.entries, options); + printReport(report, options, { ...source, logPath: options.logPath }); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } } diff --git a/scripts/diagnostics/analyze-flowchat-log.test.mjs b/scripts/diagnostics/analyze-flowchat-log.test.mjs new file mode 100644 index 0000000000..152f583596 --- /dev/null +++ b/scripts/diagnostics/analyze-flowchat-log.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + analyzeEntries, + collectEpisodes, + entryTravelPx, + entryWeight, + joinPlacements, + parseArgs, +} from './analyze-flowchat-log.mjs'; + +let nextSequence = 0; + +function entry(location, data, overrides = {}) { + nextSequence += 1; + return { + sequence: nextSequence, + timestamp: '2026-08-11T00:00:00.000Z', + performanceTimeMs: nextSequence * 16, + hypothesis: 'viewport', + location, + message: location, + data, + ...overrides, + }; +} + +function write(owner, fromPx, toPx, overrides = {}) { + return entry('viewportOwner.write', { + owner, + granted: true, + heldBy: null, + fromPx, + toPx, + ...overrides.data, + }, overrides.entry); +} + +test('parseArgs requires a log path and rejects unknown options', () => { + assert.throws(() => parseArgs([]), /log path is required/); + assert.throws(() => parseArgs(['flowchat.log', '--nope']), /Unknown option/); + + const options = parseArgs(['flowchat.log', '--min-drift', '20', '--tag', 'viewport']); + assert.equal(options.logPath, 'flowchat.log'); + assert.equal(options.minDrift, 20); + assert.deepEqual(options.tags, ['viewport']); + + // `npm run … -- ` hands the separator through as an argument. + assert.equal(parseArgs(['--', 'flowchat.log']).logPath, 'flowchat.log'); +}); + +test('a coalesced entry counts as the run it stands for', () => { + const coalesced = write('follow-output', 100, 102, { + data: { repeated: { suppressedCount: 59, suppressedTravelPx: 118, suppressedForMs: 480 } }, + }); + + assert.equal(entryWeight(coalesced), 60); + assert.equal(entryTravelPx(coalesced), 120); + // An entry with no run behind it still stands for itself. + assert.equal(entryWeight(write('follow-output', 0, 10)), 1); +}); + +test('episodes split on a quiet gap and score a fight by its churn', () => { + nextSequence = 0; + const fight = [ + write('snap-back', 1000, 1000.7), + write('anchor-correction', 1000.7, 1000), + write('snap-back', 1000, 1000.7), + write('anchor-correction', 1000.7, 1000), + ]; + const later = write('follow-output', 1000, 2000, { + entry: { performanceTimeMs: 20_000 }, + }); + + const episodes = collectEpisodes([...fight, later], 750); + + assert.equal(episodes.length, 2); + // Four writes that went nowhere: travel without progress is the whole signal. + assert.equal(episodes[0].writes, 4); + assert.equal(episodes[0].netPx, 0); + assert.ok(episodes[0].churn >= 2, `expected churn, got ${episodes[0].churn}`); + assert.equal(episodes[1].netPx, 1000); + assert.equal(episodes[1].churn, 1); +}); + +test('refused writes are counted apart from the ones that moved', () => { + nextSequence = 0; + const episodes = collectEpisodes([ + write('follow-output', 500, 600), + write('anchor-correction', 600, 500, { data: { granted: false, heldBy: 'user-gesture' } }), + ], 750); + + assert.equal(episodes[0].writes, 1); + assert.equal(episodes[0].refusals, 1); + // A refusal moved nothing, so it cannot contribute to net displacement. + assert.equal(episodes[0].netPx, 100); +}); + +test('placements pair with their outcome in order, and a stray outcome is reported', () => { + nextSequence = 0; + const first = entry('turnNavigation.placed', { beforePx: 0, placedPx: 800, targetPx: 800 }); + const second = entry('turnNavigation.placed', { beforePx: 800, placedPx: 1600, targetPx: 1600 }); + const firstOutcome = entry('turnNavigation.placed.outcome', { settledPx: 40, driftPx: -760 }); + const secondOutcome = entry('turnNavigation.placed.outcome', { settledPx: 1600, driftPx: 0 }); + const stray = entry('visibleTask.scrollToTask.outcome', { settledPx: 10, driftPx: -5 }); + + const { placements, orphanedOutcomes } = joinPlacements([ + first, + second, + firstOutcome, + secondOutcome, + stray, + ]); + + assert.equal(placements.length, 2); + assert.equal(placements[0].outcome, firstOutcome); + assert.equal(placements[1].outcome, secondOutcome); + assert.deepEqual(orphanedOutcomes, [ + { sequence: stray.sequence, location: 'visibleTask.scrollToTask' }, + ]); +}); + +test('analyzeEntries reports what did not stick, who was refused, and who declined', () => { + nextSequence = 0; + const report = analyzeEntries([ + entry('visibleTask.scrollToTask', { beforePx: 0, placedPx: 900, targetPx: 900 }), + write('anchor-correction', 900, 120), + entry('visibleTask.scrollToTask.outcome', { settledPx: 120, driftPx: -780 }), + write('follow-output', 120, 200, { data: { granted: false, heldBy: 'user-gesture' } }), + entry('snapBack.declined', { reason: 'follow-correcting' }), + entry('snapBack.declined', { reason: 'follow-correcting' }), + entry('followOutput.deferNewTurn', { turnId: 't-42' }), + { ...entry('history_paging_requested', { direction: 'before' }), hypothesis: 'history-paging' }, + ], { minDrift: 8 }); + + assert.equal(report.unstuckPlacements.length, 1); + assert.equal(report.unstuckPlacements[0].driftPx, -780); + assert.equal(report.unstuckPlacements[0].location, 'visibleTask.scrollToTask'); + + assert.deepEqual(report.refusals, [ + { owner: 'follow-output', refusedBy: 'user-gesture', count: 1 }, + ]); + + const declined = report.declines.find(row => row.location === 'snapBack.declined'); + assert.deepEqual(declined, { + location: 'snapBack.declined', + reason: 'follow-correcting', + count: 2, + }); + assert.ok(report.declines.some(row => row.location === 'followOutput.deferNewTurn')); + + // The paging tag is counted but never mistaken for viewport activity. + assert.equal(report.viewportEntryCount, 7); + assert.ok(report.frequency.some(row => row.tag === 'history-paging')); +}); + +test('a placement whose outcome never arrived is not reported as having stuck', () => { + nextSequence = 0; + const report = analyzeEntries([ + entry('navigation.scrollIntoView', { beforePx: 0, placedPx: 300 }), + ], {}); + + assert.equal(report.placementCount, 1); + assert.equal(report.unstuckPlacements.length, 0); + assert.equal(report.unsampledPlacements.length, 1); + assert.equal(report.unsampledPlacements[0].settled, 'unknown'); +}); + +test('dropped entries are surfaced, because every count becomes a lower bound', () => { + nextSequence = 0; + const report = analyzeEntries([ + { ...entry('FlowChatDiagnosticsRecorder.flush', { droppedEntries: 128 }), hypothesis: 'I' }, + write('follow-output', 0, 100), + ], {}); + + assert.equal(report.droppedEntries, 128); +}); + +test('--tag keeps only the requested stream', () => { + nextSequence = 0; + const report = analyzeEntries([ + write('follow-output', 0, 100), + { ...entry('history_paging_requested', { direction: 'before' }), hypothesis: 'history-paging' }, + ], { tags: ['history-paging'] }); + + assert.equal(report.entryCount, 1); + assert.equal(report.viewportEntryCount, 0); + assert.equal(report.episodes.length, 0); +}); diff --git a/scripts/frontend-build-all.mjs b/scripts/frontend-build-all.mjs index 4813cb1efe..00ad2c603c 100644 --- a/scripts/frontend-build-all.mjs +++ b/scripts/frontend-build-all.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env node /** - * Runs the two independent frontend build pipelines in parallel: - * - build:web (type-check + vite build + monaco asset verify) - * - prepare:mobile-web (mobile-web install/build with mtime short-circuit) + * Runs the independent pre-bundle build pipelines in parallel: + * - build:web (type-check + vite build + monaco asset verify) + * - prepare:mobile-web (mobile-web install/build with mtime short-circuit) + * - prepare:dsh-profile (the DeepSeek Harness bridge official desktop:build ships) * - * Used as the Tauri beforeBuildCommand so the frontend stage costs - * max(build:web, mobile-web) instead of their sum. Either failure fails the - * whole script with a non-zero exit code. + * Used as the Tauri beforeBuildCommand so the stage costs max(…) instead of + * their sum. Any failure fails the whole script with a non-zero exit code. */ import { spawn } from 'node:child_process'; @@ -58,6 +58,10 @@ function runPrefixed(prefix, command, args) { const codes = await Promise.all([ runPrefixed('web', 'pnpm', ['run', 'build:web']), runPrefixed('mobile-web', 'pnpm', ['run', 'prepare:mobile-web']), + // The DeepSeek Harness bridge Tauri ships as a resource. On a cold tree this + // installs its own pinned toolchain (~30s), which still fits inside the two + // above; it is independent of them, and of BitFun's pnpm store. + runPrefixed('dsh-profile', 'pnpm', ['run', 'prepare:dsh-profile']), ]); const failed = codes.some((code) => code !== 0); diff --git a/scripts/generate-version.cjs b/scripts/generate-version.cjs index 0081e52f2e..d84d8d50b5 100644 --- a/scripts/generate-version.cjs +++ b/scripts/generate-version.cjs @@ -59,6 +59,10 @@ function generateVersionInfo(buildEnv) { const buildDate = new Date().toISOString(); const buildTimestamp = Date.now(); const isDev = buildEnv === 'development'; + const releaseChannel = process.env.BITFUN_RELEASE_CHANNEL || 'stable'; + if (!['stable', 'beta', 'nightly'].includes(releaseChannel)) { + throw new Error(`Unsupported BITFUN_RELEASE_CHANNEL: ${releaseChannel}`); + } const versionInfo = { name: packageJson.name === 'BitFun' ? 'BitFun' : packageJson.name, @@ -66,6 +70,7 @@ function generateVersionInfo(buildEnv) { buildDate, buildTimestamp, buildEnv, + releaseChannel, isDev, ...gitInfo }; @@ -146,4 +151,3 @@ try { process.exit(1); } - diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs index 01dcc038f4..e3d9e00d17 100644 --- a/scripts/linux-binaries-manifest.test.mjs +++ b/scripts/linux-binaries-manifest.test.mjs @@ -374,15 +374,17 @@ test('website download manifest uses installer while updater manifest keeps setu ); }); -test('Linux archives are mirrored before the much larger Desktop packages', () => { +test('stable Linux archives are mirrored before the much larger Desktop packages', () => { const syncScript = fs.readFileSync( path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), 'utf8' ); - const linuxCall = syncScript.indexOf('\n mirror_linux_binaries\n'); + const stableBranch = syncScript.indexOf('if [ "$RELEASE_CHANNEL" = "stable" ]; then'); + const linuxCall = syncScript.indexOf('\n mirror_linux_binaries\n', stableBranch); const desktopLoop = syncScript.indexOf('Mirroring Desktop asset'); - assert.ok(linuxCall > 0, 'mirror_linux_binaries must be called from main'); + assert.ok(stableBranch > 0, 'stable channel branch must exist'); + assert.ok(linuxCall > stableBranch, 'stable main path must call mirror_linux_binaries'); assert.ok(desktopLoop > 0, 'Desktop asset mirroring must still exist'); assert.ok( linuxCall < desktopLoop, @@ -406,3 +408,44 @@ test('the mirror retains enough releases for older Desktop builds', () => { `KEEP_VERSIONS must retain several releases, got ${keep[1]}` ); }); + +test('openbitfun sync lock and cron use the in-repo script, not a server-only copy', () => { + const syncScript = fs.readFileSync( + path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), + 'utf8' + ); + const cronFile = fs.readFileSync( + path.join(repoRoot, 'deploy/openbitfun-host/release-sync.cron'), + 'utf8' + ); + assert.match( + syncScript, + /BITFUN_RELEASE_SYNC_LOCK/, + 'lock path must be overridable so a new host can run the repo script' + ); + assert.match( + syncScript, + /\/root\/repos\/BitFun\/scripts\/openbitfun-release-sync\.sh/, + 'documented cron must invoke the BitFun checkout, not a detached copy' + ); + assert.doesNotMatch( + syncScript, + /LOCK_FILE="\/root\/repos\/BitFun-AutoUpdate\/sync\.lock"/, + 'a hardcoded AutoUpdate lock forces every new host to recreate a server-only tree' + ); + assert.match( + cronFile, + /\/root\/repos\/BitFun\/scripts\/openbitfun-release-sync\.sh/, + 'installed crontab must invoke the BitFun checkout, not a detached copy' + ); + assert.doesNotMatch( + cronFile, + /BitFun-AutoUpdate/, + 'the crontab file must not revive the server-only AutoUpdate copy' + ); + assert.match( + syncScript, + /LOCK_FILE="\$\{BITFUN_RELEASE_SYNC_LOCK:-\/var\/lock\/bitfun-release-sync\.lock\}"/, + 'default lock must stay off the Nginx /release/ alias' + ); +}); diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index fb9fb0426a..a873f41c63 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -3,7 +3,7 @@ # sync-release.sh — Mirror BitFun release assets from GitHub to openbitfun.com. # # Flow: -# 1. Fetch latest.json from GitHub (follows /releases/latest/download/ redirect) +# 1. Fetch the selected channel's latest.json from GitHub # 2. Mirror the signed Relay image descriptor and Linux binary manifest FIRST # (small trust metadata must not queue behind ~700 MB of Desktop packages) # 3. Download every Desktop updater package plus the standalone Windows @@ -12,16 +12,19 @@ # 5. Atomically publish versioned and root manifests # 6. Remove old version dirs, keeping only the most recent KEEP_VERSIONS # -# The published release/latest.json is the Tauri updater fallback endpoint. +# The published release/latest.json and release/beta/latest.json files are the +# stable and beta Tauri updater fallback endpoints. # When GitHub is unreachable, the desktop client automatically falls through # to https://openbitfun.com/release/latest.json and downloads from this mirror. # The published release/downloads.json is for the website. Its Windows URL uses # latest.json's manual_installers entry while the updater keeps the versioned # Tauri setup.exe URL. # -# Cron (every 10 minutes): -# */10 * * * * /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ -# >> /root/repos/BitFun-AutoUpdate/sync.log 2>&1 +# Cron (every 10 minutes). Run the in-repo script from the BitFun checkout so a +# new host only needs this repository, not a detached AutoUpdate copy: +# */10 * * * * /root/repos/BitFun/scripts/openbitfun-release-sync.sh \ +# >> /var/log/bitfun-release-sync.log 2>&1 +# Host restore: deploy/openbitfun-host/README.md # # Optional immediate trigger. The release workflow POSTs to # ${OPENBITFUN_SYNC_WEBHOOK_URL} once assets are published (see the "Request @@ -31,10 +34,14 @@ # # while true; do # nc -l -p 8787 -q 1 >/dev/null \ -# && /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ -# >> /root/repos/BitFun-AutoUpdate/sync.log 2>&1 +# && BITFUN_RELEASE_CHANNEL=stable \ +# /root/repos/BitFun/scripts/openbitfun-release-sync.sh \ +# >> /var/log/bitfun-release-sync.log 2>&1 # done # +# The current production origin is cron-only. Do not invent a detached webhook +# listener unless OPENBITFUN_SYNC_WEBHOOK_URL is actually configured. +# # Cron stays the source of truth: this script is idempotent and holds a flock, # so a webhook run and a cron run cannot collide and a missed webhook only costs # latency. Leaving the secret unset keeps the cron-only behaviour. @@ -42,17 +49,35 @@ set -euo pipefail # ── Configuration ────────────────────────────────────────────── -GITHUB_LATEST_JSON_URL="https://github.com/GCWing/BitFun/releases/latest/download/latest.json" -GITHUB_LINUX_BINARIES_URL="https://github.com/GCWing/BitFun/releases/latest/download/linux-binaries.json" -GITHUB_RELAY_IMAGE_URL="https://github.com/GCWing/BitFun/releases/latest/download/relay-image.json" -OPENBITFUN_BASE_URL="https://openbitfun.com/release" +RELEASE_CHANNEL="${BITFUN_RELEASE_CHANNEL:-stable}" +case "$RELEASE_CHANNEL" in + stable) + CHANNEL_PATH="" + GITHUB_RELEASE_ROOT="https://github.com/GCWing/BitFun/releases/latest/download" + ;; + beta) + CHANNEL_PATH="/beta" + GITHUB_RELEASE_ROOT="https://github.com/GCWing/BitFun/releases/download/channel-beta" + ;; + *) + echo "Unsupported BITFUN_RELEASE_CHANNEL: $RELEASE_CHANNEL" >&2 + exit 1 + ;; +esac +GITHUB_LATEST_JSON_URL="${GITHUB_RELEASE_ROOT}/latest.json" +GITHUB_LINUX_BINARIES_URL="${GITHUB_RELEASE_ROOT}/linux-binaries.json" +GITHUB_RELAY_IMAGE_URL="${GITHUB_RELEASE_ROOT}/relay-image.json" +OPENBITFUN_BASE_URL="https://openbitfun.com/release${CHANNEL_PATH}" # The mirror deliberately lives outside the website checkout. It used to be # BitFun-Website/dist/release, but `npm run build` empties dist/, so every # website deploy silently deleted the mirrored installers and manifests — # breaking downloads and the updater fallback until someone noticed. nginx # serves this directory through a `location ^~ /release/` alias instead. -WEBSITE_RELEASE_DIR="${WEBSITE_RELEASE_DIR:-/srv/bitfun-release}" -LOCK_FILE="/root/repos/BitFun-AutoUpdate/sync.lock" +WEBSITE_RELEASE_ROOT="${WEBSITE_RELEASE_DIR:-/srv/bitfun-release}" +WEBSITE_RELEASE_DIR="${WEBSITE_RELEASE_ROOT}${CHANNEL_PATH}" +# Keep the lock off the Nginx /release/ alias. Override with +# BITFUN_RELEASE_SYNC_LOCK if a host must share a lock across checkouts. +LOCK_FILE="${BITFUN_RELEASE_SYNC_LOCK:-/var/lock/bitfun-release-sync.lock}" LEGACY_WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME" WINDOWS_INSTALLER_URL="" @@ -495,7 +520,7 @@ main() { exit 0 fi - log "=== BitFun release sync started ===" + log "=== BitFun ${RELEASE_CHANNEL} release sync started ===" mkdir -p "$WEBSITE_RELEASE_DIR" @@ -566,10 +591,15 @@ if entry: VERSION_DIR="${WEBSITE_RELEASE_DIR}/${VERSION}" mkdir -p "$VERSION_DIR" - # 4. Mirror small trust metadata and Linux archives first. - mirror_relay_image_descriptor - mirror_linux_binaries - mirror_dispatch_macos_cli_archives + # 4. Stable owns the CLI/Relay floating manifests. The first beta slice only + # mirrors Desktop updater and installer assets under /release/beta. + if [ "$RELEASE_CHANNEL" = "stable" ]; then + mirror_relay_image_descriptor + mirror_linux_binaries + mirror_dispatch_macos_cli_archives + else + log "Skipping stable-only CLI and Relay metadata for the beta channel" + fi # 5. Download all platform installer packages # Extract "\t" pairs, then curl each one. @@ -638,7 +668,7 @@ print(json.dumps(data, indent=2)) done fi - log "=== Sync complete: version $VERSION ===" + log "=== ${RELEASE_CHANNEL} sync complete: version $VERSION ===" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/scripts/plan-channel-promotion.mjs b/scripts/plan-channel-promotion.mjs new file mode 100644 index 0000000000..6e03a27efe --- /dev/null +++ b/scripts/plan-channel-promotion.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { compareReleaseVersions } from './release-channel.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const candidatePath = requireArg(args, 'candidate'); +const currentPath = args.current; +const candidate = readVersion(candidatePath); +const current = currentPath && existsSync(currentPath) ? readVersion(currentPath) : null; +const promote = current === null || compareReleaseVersions(candidate, current) >= 0; + +console.log( + current === null + ? `[channel-promotion] Initial channel version: ${candidate}` + : `[channel-promotion] current=${current} candidate=${candidate} promote=${promote}`, +); +if (args['github-output']) { + appendFileSync(args['github-output'], `promote=${promote}\n`, 'utf8'); + appendFileSync(args['github-output'], `candidate_version=${candidate}\n`, 'utf8'); +} + +function readVersion(file) { + const data = JSON.parse(readFileSync(file, 'utf8')); + if (typeof data.version !== 'string') { + throw new Error(`Manifest has no string version: ${file}`); + } + return data.version; +} + +function parseArgs(rawArgs) { + const parsed = {}; + for (let index = 0; index < rawArgs.length; index += 2) { + const name = rawArgs[index]; + const value = rawArgs[index + 1]; + if (!name?.startsWith('--') || !value) { + throw new Error(`Invalid argument near ${name || ''}`); + } + parsed[name.slice(2)] = value; + } + return parsed; +} + +function requireArg(parsed, name) { + if (!parsed[name]) throw new Error(`Missing required --${name} argument`); + return parsed[name]; +} diff --git a/scripts/prepare-dsh-profile.mjs b/scripts/prepare-dsh-profile.mjs new file mode 100644 index 0000000000..c0c5898c9a --- /dev/null +++ b/scripts/prepare-dsh-profile.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node + +/** + * Build the DeepSeek Harness bridge into the profile directory the desktop + * bundle ships (`packages/dsh-acp/dist-profile`). + * + * BitFun launches DeepSeek Harness as `dsh --profile bitfun-acp`, and copies + * that directory into the user's own dsh install on first use — see + * `src/crates/interfaces/acp/src/client/dsh_profile.rs`. The build has to + * happen here rather than in Cargo because it is a TypeScript compile. + * + * `packages/dsh-acp` is deliberately NOT a pnpm workspace member: it pins the + * whole harness `0.1.0-rc.6` train, and making every contributor's + * `pnpm install` fetch that is a poor trade when only the desktop bundle needs + * it. So this installs the package from its own lockfile, here, once — unless + * a developer already linked a local harness checkout, in which case that + * checkout wins and no install runs. + * + * A failure here fails the build. An app that silently ships no bridge looks + * exactly like an app that ships a working one until a user tries to start a + * DeepSeek session, and that is the wrong place to find out. Set + * `BITFUN_SKIP_DSH_PROFILE=1` to opt out deliberately. + * + * Official `desktop:build` compiles this via `frontend:build-all`. desktop:dev + * and cargo check do not: the profile is not a compile-time Tauri resource. + * Local DeepSeek sessions run this script explicitly. A stamped profile whose + * inputs have not changed is left alone — the same mtime short-circuit + * mobile-web uses. Escape hatches: `--force` / + * `BITFUN_DSH_PROFILE_FORCE_BUILD=1`. + */ + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import path, { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PACKAGE_DIR = path.join(ROOT_DIR, 'packages', 'dsh-acp'); +const OUT_DIR = path.join(PACKAGE_DIR, 'dist-profile'); +const STAMP_FILENAME = '.bitfun-bridge.json'; + +/** One installed harness package is enough to tell a populated tree from a bare one. */ +const DEPS_PROBE = path.join(PACKAGE_DIR, 'node_modules', '@deepseek-ai', 'dsh-app-boot'); + +const INPUT_IGNORED_DIRS = new Set([ + 'node_modules', + 'lib', + 'dist-profile', + '.sessions', +]); + +/** Explains an empty dist-profile to whoever finds one in a build tree. */ +const PLACEHOLDER = `This BitFun build ships no DeepSeek Harness bridge. + +BITFUN_SKIP_DSH_PROFILE was set when this build ran, so packages/dsh-acp was +never compiled. BitFun's profile resolver rejects this unstamped directory and +reports that the bridge is missing from the build. + +To include it: run \`pnpm run prepare:dsh-profile\` without that variable. +`; + +/** + * Newest mtime among the profile build inputs (recursively for directories). + * @param {string} entryPath + * @returns {{ path: string, mtimeMs: number } | null} + */ +export function getNewestInputMtime(entryPath) { + if (!existsSync(entryPath)) { + return null; + } + + const stat = lstatSync(entryPath); + if (stat.isSymbolicLink()) { + return null; + } + + if (stat.isFile()) { + return { path: entryPath, mtimeMs: stat.mtimeMs }; + } + + if (!stat.isDirectory()) { + return null; + } + + let newest = null; + for (const entry of readdirSync(entryPath, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory() && INPUT_IGNORED_DIRS.has(entry.name)) { + continue; + } + const candidate = getNewestInputMtime(path.join(entryPath, entry.name)); + if (candidate && (!newest || candidate.mtimeMs > newest.mtimeMs)) { + newest = candidate; + } + } + + return newest; +} + +/** + * Decide whether the shipped profile needs a rebuild. + * + * A directory is not enough: Tauri only needs the path to exist, but BitFun's + * resolver rejects an unstamped tree. The stamp `.bitfun-bridge.json` is the + * same file `scripts/build-profile.mjs` writes and ACP reads. + * + * @param {{ + * packageDir?: string, + * outDir?: string, + * prepareScriptPath?: string, + * force?: boolean, + * }} [options] + * @returns {{ shouldBuild: boolean, reason: string }} + */ +export function getDshProfileRebuildPlan({ + packageDir = PACKAGE_DIR, + outDir = path.join(packageDir, 'dist-profile'), + prepareScriptPath = path.join(ROOT_DIR, 'scripts', 'prepare-dsh-profile.mjs'), + force = false, +} = {}) { + if (force) { + return { shouldBuild: true, reason: 'Force rebuild requested for dsh-profile' }; + } + + const stampPath = path.join(outDir, STAMP_FILENAME); + if (!existsSync(stampPath)) { + return { shouldBuild: true, reason: 'dsh-profile stamp is missing' }; + } + + try { + const stamp = JSON.parse(readFileSync(stampPath, 'utf8')); + if ( + typeof stamp.profile !== 'string' || + stamp.profile === '' || + typeof stamp.content !== 'string' || + stamp.content === '' + ) { + return { shouldBuild: true, reason: 'dsh-profile stamp is incomplete' }; + } + } catch { + return { shouldBuild: true, reason: 'dsh-profile stamp is unreadable' }; + } + + const stampMtimeMs = statSync(stampPath).mtimeMs; + const inputs = [ + path.join(packageDir, 'src'), + path.join(packageDir, 'presets'), + path.join(packageDir, 'cordis.yml'), + path.join(packageDir, 'package.json'), + path.join(packageDir, 'package-lock.json'), + path.join(packageDir, 'tsconfig.build.json'), + path.join(packageDir, 'scripts', 'build-profile.mjs'), + prepareScriptPath, + ]; + + let newestInput = null; + for (const input of inputs) { + const candidate = getNewestInputMtime(input); + if (candidate && (!newestInput || candidate.mtimeMs > newestInput.mtimeMs)) { + newestInput = candidate; + } + } + + if (newestInput && newestInput.mtimeMs > stampMtimeMs) { + return { + shouldBuild: true, + reason: `dsh-profile inputs changed since the last build (${path.relative(ROOT_DIR, newestInput.path)})`, + }; + } + + return { + shouldBuild: false, + reason: + 'dsh-profile is up to date; skipping install/build (use --force or BITFUN_DSH_PROFILE_FORCE_BUILD=1 to rebuild)', + }; +} + +/** + * Run a command in the bridge package directory. + * @param {string} command - the executable to run. + * @param {string[]} args - its arguments. + * @returns {number} the exit status, with a missing status treated as a failure. + */ +function run(command, args) { + const result = spawnSync(command, args, { + cwd: PACKAGE_DIR, + shell: process.platform === 'win32', + stdio: 'inherit', + env: process.env, + }); + return result.status ?? 1; +} + +/** + * Fail the build, saying what to do about it. + * @param {string} message - what went wrong. + * @param {number} status - the exit status to propagate. + */ +function fail(message, status) { + process.stderr.write(`[dsh-profile] ${message}\n`); + process.stderr.write( + '[dsh-profile] set BITFUN_SKIP_DSH_PROFILE=1 to build without the DeepSeek bridge\n', + ); + process.exit(status === 0 ? 1 : status); +} + +function main() { + if (process.env.BITFUN_SKIP_DSH_PROFILE === '1') { + process.stdout.write('[dsh-profile] skipped (this build ships no DeepSeek bridge)\n'); + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(path.join(OUT_DIR, 'NOT-BUILT.md'), PLACEHOLDER); + return; + } + + const force = + process.argv.includes('--force') || process.env.BITFUN_DSH_PROFILE_FORCE_BUILD === '1'; + const plan = getDshProfileRebuildPlan({ force }); + if (!plan.shouldBuild) { + process.stdout.write(`[dsh-profile] ${plan.reason}\n`); + return; + } + process.stdout.write(`[dsh-profile] ${plan.reason}\n`); + + if (!existsSync(DEPS_PROBE)) { + process.stdout.write('[dsh-profile] installing the harness toolchain from package-lock.json\n'); + const installed = run('npm', ['ci', '--no-audit', '--no-fund']); + if (installed !== 0) fail('npm ci failed', installed); + } + + const compiled = run('npm', ['run', 'build']); + if (compiled !== 0) fail('tsc failed', compiled); + + const packaged = run('node', ['scripts/build-profile.mjs']); + if (packaged !== 0) fail('profile packaging failed', packaged); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/prepare-dsh-profile.test.mjs b/scripts/prepare-dsh-profile.test.mjs new file mode 100644 index 0000000000..b9729a9608 --- /dev/null +++ b/scripts/prepare-dsh-profile.test.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { getDshProfileRebuildPlan } from './prepare-dsh-profile.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function createPackageTree() { + const root = mkdtempSync(path.join(tmpdir(), 'bitfun-dsh-profile-')); + const packageDir = path.join(root, 'packages', 'dsh-acp'); + const outDir = path.join(packageDir, 'dist-profile'); + const prepareScriptPath = path.join(root, 'scripts', 'prepare-dsh-profile.mjs'); + + mkdirSync(path.join(packageDir, 'src'), { recursive: true }); + mkdirSync(path.join(packageDir, 'presets'), { recursive: true }); + mkdirSync(path.join(packageDir, 'scripts'), { recursive: true }); + mkdirSync(path.join(root, 'scripts'), { recursive: true }); + mkdirSync(outDir, { recursive: true }); + + writeFileSync(path.join(packageDir, 'src', 'app.ts'), 'export {}\n'); + writeFileSync(path.join(packageDir, 'presets', 'preset.yml'), 'name: test\n'); + writeFileSync(path.join(packageDir, 'cordis.yml'), 'name: test\n'); + writeFileSync(path.join(packageDir, 'package.json'), '{}\n'); + writeFileSync(path.join(packageDir, 'package-lock.json'), '{}\n'); + writeFileSync(path.join(packageDir, 'tsconfig.build.json'), '{}\n'); + writeFileSync(path.join(packageDir, 'scripts', 'build-profile.mjs'), 'export {}\n'); + writeFileSync(prepareScriptPath, 'export {}\n'); + + return { root, packageDir, outDir, prepareScriptPath }; +} + +function writeStamp(outDir, stamp = { profile: 'bitfun-acp', content: 'abc' }) { + const stampPath = path.join(outDir, '.bitfun-bridge.json'); + writeFileSync(stampPath, `${JSON.stringify(stamp)}\n`); + return stampPath; +} + +function setMtime(filePath, mtimeMs) { + const atime = new Date(mtimeMs); + const mtime = new Date(mtimeMs); + utimesSync(filePath, atime, mtime); +} + +test('rebuilds when the profile stamp is missing', () => { + const { root, packageDir, outDir, prepareScriptPath } = createPackageTree(); + try { + const plan = getDshProfileRebuildPlan({ packageDir, outDir, prepareScriptPath }); + assert.equal(plan.shouldBuild, true); + assert.match(plan.reason, /stamp is missing/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('rebuilds when force is requested', () => { + const { root, packageDir, outDir, prepareScriptPath } = createPackageTree(); + try { + const stampPath = writeStamp(outDir); + setMtime(stampPath, Date.now() + 60_000); + const plan = getDshProfileRebuildPlan({ + packageDir, + outDir, + prepareScriptPath, + force: true, + }); + assert.equal(plan.shouldBuild, true); + assert.match(plan.reason, /Force rebuild/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('rebuilds when the stamp is incomplete', () => { + const { root, packageDir, outDir, prepareScriptPath } = createPackageTree(); + try { + writeStamp(outDir, { profile: 'bitfun-acp' }); + const plan = getDshProfileRebuildPlan({ packageDir, outDir, prepareScriptPath }); + assert.equal(plan.shouldBuild, true); + assert.match(plan.reason, /incomplete/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('rebuilds when an input is newer than the stamp', () => { + const { root, packageDir, outDir, prepareScriptPath } = createPackageTree(); + try { + const stampPath = writeStamp(outDir); + const older = Date.now() - 60_000; + const newer = Date.now(); + for (const filePath of [ + path.join(packageDir, 'src', 'app.ts'), + path.join(packageDir, 'presets', 'preset.yml'), + path.join(packageDir, 'cordis.yml'), + path.join(packageDir, 'package.json'), + path.join(packageDir, 'package-lock.json'), + path.join(packageDir, 'tsconfig.build.json'), + path.join(packageDir, 'scripts', 'build-profile.mjs'), + prepareScriptPath, + stampPath, + ]) { + setMtime(filePath, older); + } + setMtime(path.join(packageDir, 'src', 'app.ts'), newer); + const plan = getDshProfileRebuildPlan({ packageDir, outDir, prepareScriptPath }); + assert.equal(plan.shouldBuild, true); + assert.match(plan.reason, /inputs changed/); + assert.match(plan.reason, /app\.ts/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('skips when a complete stamp is newer than every input', () => { + const { root, packageDir, outDir, prepareScriptPath } = createPackageTree(); + try { + const older = Date.now() - 60_000; + const newer = Date.now(); + for (const filePath of [ + path.join(packageDir, 'src', 'app.ts'), + path.join(packageDir, 'presets', 'preset.yml'), + path.join(packageDir, 'cordis.yml'), + path.join(packageDir, 'package.json'), + path.join(packageDir, 'package-lock.json'), + path.join(packageDir, 'tsconfig.build.json'), + path.join(packageDir, 'scripts', 'build-profile.mjs'), + prepareScriptPath, + ]) { + setMtime(filePath, older); + } + const stampPath = writeStamp(outDir); + setMtime(stampPath, newer); + const plan = getDshProfileRebuildPlan({ packageDir, outDir, prepareScriptPath }); + assert.equal(plan.shouldBuild, false); + assert.match(plan.reason, /up to date/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('desktop:dev does not compile the DeepSeek profile', () => { + const devScript = readFileSync(path.join(repoRoot, 'scripts', 'dev.cjs'), 'utf8'); + assert.doesNotMatch(devScript, /runCommandPrefixed\('dsh-profile'/); + assert.doesNotMatch(devScript, /\['run', 'prepare:dsh-profile'\]/); + assert.match( + devScript, + /Prepare resources \(parallel: monaco, version, mobile-web, flashgrep\)/, + ); +}); + +test('desktop clippy does not compile the DeepSeek profile', () => { + const packageJson = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + assert.doesNotMatch(packageJson.scripts['lint:rs:desktop'], /prepare:dsh-profile/); +}); + +test('official frontend packaging still compiles the DeepSeek profile', () => { + const frontendBuildAll = readFileSync( + path.join(repoRoot, 'scripts', 'frontend-build-all.mjs'), + 'utf8', + ); + assert.match(frontendBuildAll, /prepare:dsh-profile/); +}); diff --git a/scripts/release-channel.mjs b/scripts/release-channel.mjs new file mode 100644 index 0000000000..358cd24db6 --- /dev/null +++ b/scripts/release-channel.mjs @@ -0,0 +1,82 @@ +const CHANNELS = { + stable: { + primaryUpdaterEndpoint: + 'https://github.com/GCWing/BitFun/releases/latest/download/latest.json', + fallbackUpdaterEndpoint: 'https://openbitfun.com/release/latest.json', + githubChannelTag: null, + }, + beta: { + primaryUpdaterEndpoint: + 'https://github.com/GCWing/BitFun/releases/download/channel-beta/latest.json', + fallbackUpdaterEndpoint: 'https://openbitfun.com/release/beta/latest.json', + githubChannelTag: 'channel-beta', + }, + nightly: { + primaryUpdaterEndpoint: + 'https://github.com/GCWing/BitFun/releases/download/nightly/latest.json', + fallbackUpdaterEndpoint: 'https://openbitfun.com/release/nightly/latest.json', + githubChannelTag: 'nightly', + }, +}; + +export function resolveReleaseChannel(value = 'stable') { + const channel = String(value || 'stable').trim().toLowerCase(); + const config = CHANNELS[channel]; + if (!config) { + throw new Error( + `Unsupported release channel "${value}". Expected one of: ${Object.keys(CHANNELS).join(', ')}`, + ); + } + return { channel, ...config }; +} + +export function validateReleaseVersion(channelValue, versionValue) { + const { channel } = resolveReleaseChannel(channelValue); + const version = String(versionValue || '').trim(); + const stablePattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + const betaPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.([1-9]\d*)$/; + const nightlyPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-nightly\.\d{8}$/; + const valid = channel === 'stable' + ? stablePattern.test(version) + : channel === 'beta' + ? betaPattern.test(version) + : nightlyPattern.test(version); + if (!valid) { + const example = channel === 'stable' + ? '0.2.18' + : channel === 'beta' + ? '0.2.18-beta.1' + : '0.2.18-nightly.20260811'; + throw new Error( + `Version "${versionValue}" is invalid for the ${channel} channel. Expected a version like ${example}`, + ); + } + return version; +} + +export function compareReleaseVersions(leftValue, rightValue) { + const left = parseComparableVersion(leftValue); + const right = parseComparableVersion(rightValue); + for (let index = 0; index < 3; index += 1) { + if (left.core[index] !== right.core[index]) { + return left.core[index] < right.core[index] ? -1 : 1; + } + } + if (left.beta === right.beta) return 0; + if (left.beta === null) return 1; + if (right.beta === null) return -1; + return left.beta < right.beta ? -1 : 1; +} + +function parseComparableVersion(value) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.([1-9]\d*))?$/.exec(String(value)); + if (!match) { + throw new Error(`Unsupported channel version: ${value}`); + } + return { + core: match.slice(1, 4).map(Number), + beta: match[4] === undefined ? null : Number(match[4]), + }; +} diff --git a/scripts/release-channel.test.mjs b/scripts/release-channel.test.mjs new file mode 100644 index 0000000000..02bed3d72e --- /dev/null +++ b/scripts/release-channel.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + compareReleaseVersions, + resolveReleaseChannel, + validateReleaseVersion, +} from './release-channel.mjs'; +import { setBuildVersion } from './set-build-version.mjs'; +import { decodeMinisignPublicKey } from './write-minisign-public-key.mjs'; + +const RAW_PUBLIC_KEY = `untrusted comment: minisign public key E3E0874CEC1C22C3 +RWTDIhzsTIfg41w2Gwiei0zNDKaLYm9dQVpEWNQ/Ulpyt2mbS2JE1U2M`; + +test('stable and beta channels resolve to isolated updater feeds', () => { + const stable = resolveReleaseChannel('stable'); + const beta = resolveReleaseChannel('beta'); + assert.match(stable.primaryUpdaterEndpoint, /releases\/latest\/download/); + assert.match(beta.primaryUpdaterEndpoint, /releases\/download\/channel-beta/); + assert.equal(beta.fallbackUpdaterEndpoint, 'https://openbitfun.com/release/beta/latest.json'); + assert.notEqual(beta.primaryUpdaterEndpoint, stable.primaryUpdaterEndpoint); +}); + +test('channel promotion follows SemVer including beta precedence', () => { + assert.equal(compareReleaseVersions('0.2.18-beta.2', '0.2.18-beta.1'), 1); + assert.equal(compareReleaseVersions('0.2.18', '0.2.18-beta.9'), 1); + assert.equal(compareReleaseVersions('0.2.19-beta.1', '0.2.18'), 1); + assert.equal(compareReleaseVersions('0.2.18', '0.2.19-beta.1'), -1); + assert.equal(compareReleaseVersions('0.2.18-beta.2', '0.2.18-beta.2'), 0); +}); + +test('release versions must match their channel', () => { + assert.equal(validateReleaseVersion('stable', '0.2.18'), '0.2.18'); + assert.equal(validateReleaseVersion('beta', '0.2.18-beta.1'), '0.2.18-beta.1'); + assert.throws(() => validateReleaseVersion('stable', '0.2.18-beta.1')); + assert.throws(() => validateReleaseVersion('beta', '0.2.18')); + assert.throws(() => validateReleaseVersion('beta', '0.2.18-beta.0')); + assert.equal( + validateReleaseVersion('nightly', '0.2.18-nightly.20260811'), + '0.2.18-nightly.20260811', + ); +}); + +test('release public key export accepts raw and legacy base64 values', () => { + const expected = `${RAW_PUBLIC_KEY}\n`; + assert.equal(decodeMinisignPublicKey(RAW_PUBLIC_KEY), expected); + assert.equal( + decodeMinisignPublicKey(Buffer.from(expected).toString('base64')), + expected, + ); + assert.throws(() => decodeMinisignPublicKey('not-a-key')); +}); + +test('build version projection updates every release-owned version file', () => { + const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-version-')); + const jsonFiles = [ + 'package.json', + 'package-lock.json', + 'BitFun-Installer/package.json', + 'BitFun-Installer/package-lock.json', + ]; + for (const relative of jsonFiles) { + const file = path.join(root, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify({ version: '1.0.0', packages: { '': { version: '1.0.0' } } })); + } + writeFixture(root, 'Cargo.toml', 'version = "1.0.0" # x-release-please-version\n'); + writeFixture( + root, + 'src/apps/relay-server/Cargo.toml', + 'version = "1.0.0" # x-release-please-version\n', + ); + writeFixture(root, 'BitFun-Installer/src-tauri/Cargo.toml', 'version = "1.0.0"\n'); + + setBuildVersion(root, '1.1.0-beta.2'); + + for (const relative of jsonFiles) { + const data = JSON.parse(readFileSync(path.join(root, relative), 'utf8')); + assert.equal(data.version, '1.1.0-beta.2'); + assert.equal(data.packages[''].version, '1.1.0-beta.2'); + } + assert.match(readFileSync(path.join(root, 'Cargo.toml'), 'utf8'), /1\.1\.0-beta\.2/); + assert.match( + readFileSync(path.join(root, 'src/apps/relay-server/Cargo.toml'), 'utf8'), + /1\.1\.0-beta\.2/, + ); +}); + +function writeFixture(root, relative, content) { + const file = path.join(root, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); +} diff --git a/scripts/set-build-version.mjs b/scripts/set-build-version.mjs new file mode 100644 index 0000000000..deaaa91ddb --- /dev/null +++ b/scripts/set-build-version.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +export function setBuildVersion(root, version) { + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid build version: ${version}`); + } + + for (const relative of [ + 'package.json', + 'package-lock.json', + 'BitFun-Installer/package.json', + 'BitFun-Installer/package-lock.json', + ]) { + const file = path.join(root, relative); + const data = JSON.parse(readFileSync(file, 'utf8')); + data.version = version; + if (data.packages?.['']) { + data.packages[''].version = version; + } + writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + } + + replaceVersion( + path.join(root, 'Cargo.toml'), + /^version = "[^"]+" # x-release-please-version$/m, + `version = "${version}" # x-release-please-version`, + ); + replaceVersion( + path.join(root, 'src/apps/relay-server/Cargo.toml'), + /^version = "[^"]+" # x-release-please-version$/m, + `version = "${version}" # x-release-please-version`, + ); + replaceVersion( + path.join(root, 'BitFun-Installer/src-tauri/Cargo.toml'), + /^version = "[^"]+"$/m, + `version = "${version}"`, + ); +} + +function replaceVersion(file, pattern, replacement) { + const source = readFileSync(file, 'utf8'); + if (!pattern.test(source)) { + throw new Error(`Version marker was not found in ${file}`); + } + writeFileSync(file, source.replace(pattern, replacement), 'utf8'); +} + +function readArg(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const version = readArg(process.argv.slice(2), '--version'); + if (!version) { + throw new Error('Missing required --version argument'); + } + setBuildVersion(ROOT, version); + console.log(`[build-version] Updated release metadata to ${version}`); + } catch (error) { + console.error(`[build-version] ${error.message || error}`); + process.exit(1); + } +} diff --git a/scripts/sign-release-assets.sh b/scripts/sign-release-assets.sh index f094711654..84370c5517 100755 --- a/scripts/sign-release-assets.sh +++ b/scripts/sign-release-assets.sh @@ -6,9 +6,11 @@ # Usage: sign-release-assets.sh [...] # # Environment: -# BITFUN_SIGNING_KEY minisign secret key, base64 (Tauri's wrapper format) +# BITFUN_SIGNING_KEY minisign secret key, either the raw key file or +# base64 of that file (legacy wrapper format) # BITFUN_SIGNING_PASSWORD password for that key -# BITFUN_SIGNING_PUBKEY minisign public key, base64; used to self-verify +# BITFUN_SIGNING_PUBKEY minisign public key, either the raw key file or +# base64 of that file; used to self-verify # # With no signing key configured this is a no-op, so forks keep building. # @@ -88,10 +90,25 @@ WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT umask 077 -# Tauri stores the minisign secret key base64-wrapped; unwrap it to the on-disk -# format minisign expects. -printf '%s' "$BITFUN_SIGNING_KEY" | base64 -d >"$WORK/release.key" -printf '%s' "${BITFUN_SIGNING_PUBKEY:-}" | base64 -d >"$WORK/release.pub" 2>/dev/null || true +# New Tauri CLIs accept the raw minisign key file as their environment value; +# older repository secrets wrap that whole file in base64. Support both so the +# Tauri bundler and direct minisign asset signing can share one secret. +case "$BITFUN_SIGNING_KEY" in + "untrusted comment:"*) + printf '%s\n' "$BITFUN_SIGNING_KEY" >"$WORK/release.key" + ;; + *) + printf '%s' "$BITFUN_SIGNING_KEY" | base64 -d >"$WORK/release.key" + ;; +esac +case "${BITFUN_SIGNING_PUBKEY:-}" in + "untrusted comment:"*) + printf '%s\n' "$BITFUN_SIGNING_PUBKEY" >"$WORK/release.pub" + ;; + *) + printf '%s' "${BITFUN_SIGNING_PUBKEY:-}" | base64 -d >"$WORK/release.pub" 2>/dev/null || true + ;; +esac signed=0 skipped=0 diff --git a/scripts/verify-webkit-compatibility.cjs b/scripts/verify-webkit-compatibility.cjs new file mode 100644 index 0000000000..cce0df65d0 --- /dev/null +++ b/scripts/verify-webkit-compatibility.cjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const DEFAULT_DIST_DIR = path.join(ROOT_DIR, 'dist'); +const FORBIDDEN_PATTERNS = [ + { + label: 'remark-gfm variable-length email lookbehind', + source: '(?<=^|\\s|\\p{P}|\\p{S})', + }, +]; + +function collectJavaScriptFiles(directory) { + if (!fs.existsSync(directory)) { + return []; + } + + const files = []; + const entries = fs.readdirSync(directory, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectJavaScriptFiles(entryPath)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(entryPath); + } + } + return files; +} + +function findWebKitCompatibilityViolations(directory = DEFAULT_DIST_DIR) { + const violations = []; + for (const filePath of collectJavaScriptFiles(directory)) { + const content = fs.readFileSync(filePath, 'utf8'); + for (const pattern of FORBIDDEN_PATTERNS) { + if (content.includes(pattern.source)) { + violations.push({ filePath, label: pattern.label }); + } + } + } + return violations; +} + +function main() { + if (!fs.existsSync(DEFAULT_DIST_DIR)) { + console.error('[verify-webkit-compatibility] Production output directory is missing.'); + process.exitCode = 1; + return; + } + + const violations = findWebKitCompatibilityViolations(); + if (violations.length === 0) { + console.log( + '[verify-webkit-compatibility] Known Markdown WebKit incompatibilities were not found.' + ); + return; + } + + console.error('[verify-webkit-compatibility] Unsupported JavaScript found:'); + for (const violation of violations) { + console.error( + ` - ${path.relative(ROOT_DIR, violation.filePath)}: ${violation.label}` + ); + } + process.exitCode = 1; +} + +if (require.main === module) { + main(); +} + +module.exports = { findWebKitCompatibilityViolations }; diff --git a/scripts/verify-webkit-compatibility.test.mjs b/scripts/verify-webkit-compatibility.test.mjs new file mode 100644 index 0000000000..a5868ba0f5 --- /dev/null +++ b/scripts/verify-webkit-compatibility.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { findWebKitCompatibilityViolations } = require('./verify-webkit-compatibility.cjs'); + +function withTempDist(callback) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-webkit-compat-')); + try { + return callback(directory); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +test('accepts production JavaScript without the incompatible lookbehind', () => { + withTempDist((directory) => { + fs.writeFileSync(path.join(directory, 'app.js'), 'const email = /[-.\\w+]+@example\\.com/gu;'); + assert.deepEqual(findWebKitCompatibilityViolations(directory), []); + }); +}); + +test('rejects the remark-gfm variable-length email lookbehind', () => { + withTempDist((directory) => { + const filePath = path.join(directory, 'app.js'); + fs.writeFileSync( + filePath, + String.raw`const email = /(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@example/gu;` + ); + + assert.deepEqual(findWebKitCompatibilityViolations(directory), [ + { + filePath, + label: 'remark-gfm variable-length email lookbehind', + }, + ]); + }); +}); diff --git a/scripts/version-generation.test.mjs b/scripts/version-generation.test.mjs index bd599fcfea..6750c23073 100644 --- a/scripts/version-generation.test.mjs +++ b/scripts/version-generation.test.mjs @@ -18,10 +18,24 @@ for (const [buildEnv, isDev] of [['production', false], ['development', true]]) ); assert.equal(generated.version, expectedVersion); assert.equal(generated.buildEnv, buildEnv); + assert.equal(generated.releaseChannel, 'stable'); assert.equal(generated.isDev, isDev); }); } +test('records the immutable release channel in generated metadata', () => { + const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-version-beta-')); + const result = run( + ['--build-env', 'production', '--output-root', outputRoot], + { BITFUN_RELEASE_CHANNEL: 'beta' }, + ); + assert.equal(result.status, 0, result.stderr); + const generated = JSON.parse( + fs.readFileSync(path.join(outputRoot, 'src/web-ui/public/version.json'), 'utf8') + ); + assert.equal(generated.releaseChannel, 'beta'); +}); + test('fails instead of reusing stale metadata when build environment is missing', () => { const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-version-missing-')); const result = run(['--output-root', outputRoot]); @@ -29,9 +43,10 @@ test('fails instead of reusing stale metadata when build environment is missing' assert.match(`${result.stdout}\n${result.stderr}`, /Expected --build-env/); }); -function run(args) { +function run(args, extraEnv = {}) { return spawnSync(process.execPath, ['scripts/generate-version.cjs', ...args], { cwd: root, encoding: 'utf8', + env: { ...process.env, ...extraEnv }, }); } diff --git a/scripts/write-minisign-public-key.mjs b/scripts/write-minisign-public-key.mjs new file mode 100644 index 0000000000..900fab03cb --- /dev/null +++ b/scripts/write-minisign-public-key.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function decodeMinisignPublicKey(value) { + const input = String(value || '').trim(); + if (!input) { + throw new Error('BITFUN_SIGNING_PUBKEY is required'); + } + + const raw = input.startsWith('untrusted comment:') + ? input + : decodeBase64(input); + const lines = raw.trim().split(/\r?\n/); + if (!lines[0]?.startsWith('untrusted comment:') || lines.length < 2 || !lines[1]) { + throw new Error('Public key is not a minisign public key file'); + } + return `${raw.trim()}\n`; +} + +function decodeBase64(value) { + const compact = value.replace(/\s/g, ''); + if ( + compact.length === 0 + || compact.length % 4 !== 0 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(compact) + ) { + throw new Error('Public key is neither raw minisign text nor valid base64'); + } + return Buffer.from(compact, 'base64').toString('utf8'); +} + +function readArg(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const output = readArg(process.argv.slice(2), '--out'); + if (!output) throw new Error('Missing required --out argument'); + writeFileSync(output, decodeMinisignPublicKey(process.env.BITFUN_SIGNING_PUBKEY), 'utf8'); + } catch (error) { + console.error(`[release-key] ${error.message || error}`); + process.exit(1); + } +} diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000000..95e3f5e159 --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,64 @@ +# BitFun Agent SDK for TypeScript + +This package is a repository-internal vertical slice. It is private, reports +`not_delivered` through the Host handshake, and must not be published or +described as a Preview SDK yet. + +The slice validates the intended public object model: + +- one application-level `AgentClient` owns one managed native + `bitfun-sdk-host` process and one Host connection; +- `client.query()` uses a Host-managed transient Session; +- `client.sessions.create()` creates an explicit Session whose Turns reuse the + same connection and existing Agent Runtime owner; +- `Query` is an ordered async stream with idempotent cancellation, cached final + `Result`, and explicit close semantics; +- protocol and process failures use `SdkError`, including outcome certainty. + +Lifecycle cleanup is bounded. The Windows Host contains descendants in a +kill-on-close Job Object, while Unix managed Hosts run in an isolated process +group. A cleanup result whose outcome is unknown makes the connection +unusable and triggers Host reclamation. + +It does not start the CLI or the Node/Bun Plugin Host, and it does not implement +another Agent Runtime. The managed native Host adapts this package to the +existing `agent-runtime::sdk` API. + +## Repository usage + +Build `bitfun-sdk-host`, then pass its absolute path while the platform-native +package layout is still pending: + +```typescript +import { AgentClient } from "@bitfun/agent-sdk"; + +await using client = await AgentClient.start({ + cwd: process.cwd(), + hostPath: process.env.BITFUN_SDK_HOST_PATH, +}); + +await using query = await client.query({ prompt: "Summarize this repository" }); +for await (const item of query) { + if (item.type === "assistant_text_delta") { + process.stdout.write(item.text); + } +} +const result = await query.result(); +``` + +`BITFUN_SDK_HOST_PATH` is also read directly when `hostPath` is omitted. The +eventual installable package must bundle or resolve a matching signed Host; it +must not require a separately installed BitFun CLI. + +## Development + +```bash +pnpm --dir sdk/typescript test +pnpm --dir sdk/typescript type-check +``` + +The internal TypeScript wire bindings are generated from the Rust SDK Host +protocol with `ts-rs`. Runtime validators are generated from those same +bindings, while only JSON-RPC envelope and cross-field semantic checks remain +hand-written. The generated sources are deliberately not the public API and +are not checked in. diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000000..735786447e --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,33 @@ +{ + "name": "@bitfun/agent-sdk", + "version": "0.0.0", + "private": true, + "description": "Internal TypeScript vertical slice for the BitFun Agent SDK", + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "exports": { + ".": { + "types": "./dist/sdk/typescript/src/index.d.ts", + "import": "./dist/sdk/typescript/src/index.js" + } + }, + "files": [ + "dist/sdk/typescript/src/*.d.ts", + "dist/sdk/typescript/src/*.js", + "dist/sdk/typescript/src/internal/*.js", + "dist/src/crates/adapters/transport/typescript/src/*.js", + "README.md" + ], + "scripts": { + "build": "pnpm run generate:wire && tsc -p tsconfig.json", + "generate:wire": "node scripts/generate-wire.mjs", + "test": "pnpm run build && node --test scripts/*.test.mjs dist/src/crates/adapters/transport/typescript/test/**/*.test.js dist/sdk/typescript/test/**/*.test.js", + "type-check": "pnpm run generate:wire && tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "22.19.7", + "typescript": "~5.8.3" + } +} diff --git a/sdk/typescript/scripts/generate-wire.mjs b/sdk/typescript/scripts/generate-wire.mjs new file mode 100644 index 0000000000..bd94f6b863 --- /dev/null +++ b/sdk/typescript/scripts/generate-wire.mjs @@ -0,0 +1,106 @@ +import { spawn } from "node:child_process"; +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { generateRuntimeValidators } from "./runtime-validator-generator.mjs"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(packageRoot, "../.."); +const outputDirectory = join(packageRoot, "src", "internal", "wire"); +const validatorOutput = join( + packageRoot, + "src", + "internal", + "wire-validators.ts", +); + +await rm(outputDirectory, { recursive: true, force: true }); +await rm(validatorOutput, { force: true }); +await mkdir(outputDirectory, { recursive: true }); + +await run( + "cargo", + [ + "test", + "--locked", + "-p", + "bitfun-sdk-host", + "--no-default-features", + "--features", + "ts", + "--lib", + "export", + "--", + "--nocapture", + ], + { + ...process.env, + TS_RS_EXPORT_DIR: outputDirectory, + }, +); + +const files = (await readdir(outputDirectory)) + .filter((file) => file.endsWith(".ts") && file !== "index.ts") + .map((file) => file.slice(0, -3)) + .sort(); +const requiredTypes = [ + "ErrorData", + "HostCapabilities", + "InitializeParams", + "InitializeResult", + "QueryCancelParams", + "QueryCancelResult", + "QueryEventParams", + "QueryResultParams", + "QueryStartParams", + "QueryStartResult", + "SessionCloseParams", + "SessionCloseResult", + "SessionCreateParams", + "SessionCreateResult", + "ShutdownResult", +]; +const missing = requiredTypes.filter((type) => !files.includes(type)); +if (missing.length > 0) { + throw new Error(`SDK Host wire generation missed: ${missing.join(", ")}`); +} + +await writeFile( + validatorOutput, + await generateRuntimeValidators(outputDirectory, { + typeImportPath: "./wire/index.js", + }), +); + +const header = `// GENERATED CODE! DO NOT MODIFY BY HAND!\n// Source: bitfun-sdk-host protocol types via ts-rs.\n`; +const exports = files.map( + (file) => `export type { ${file} } from "./${file}.js";`, +); +await writeFile( + join(outputDirectory, "index.ts"), + `${header}\n${exports.join("\n")}\n`, +); + +async function run(command, args, env) { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd: repositoryRoot, + env, + shell: false, + stdio: "inherit", + }); + child.once("error", rejectPromise); + child.once("exit", (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + rejectPromise( + new Error( + `${command} ${args.join(" ")} failed with ${signal ?? `exit ${code}`}`, + ), + ); + }); + }); +} diff --git a/sdk/typescript/scripts/generated-wire-runtime.test.mjs b/sdk/typescript/scripts/generated-wire-runtime.test.mjs new file mode 100644 index 0000000000..fa8306cf86 --- /dev/null +++ b/sdk/typescript/scripts/generated-wire-runtime.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import ts from "typescript"; + +const scriptsDirectory = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(scriptsDirectory, ".."); +const wireDirectory = join(packageRoot, "src", "internal", "wire"); +const validatorPath = join(packageRoot, "src", "internal", "wire-validators.ts"); + +test("Rust wire export produces executable validators for every type", async () => { + const typeNames = (await readdir(wireDirectory)) + .filter((file) => file.endsWith(".ts") && file !== "index.ts") + .map((file) => file.slice(0, -3)) + .sort(); + const validatorSource = await readFile(validatorPath, "utf8").catch((error) => { + if (error?.code === "ENOENT") { + throw new Error( + "generated wire validators are missing; run the SDK build before this test", + ); + } + throw error; + }); + const javascript = ts.transpileModule(validatorSource, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const validators = await import( + `data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}` + ); + + for (const typeName of typeNames) { + assert.equal( + typeof validators[`is${typeName}`], + "function", + `${typeName} must have an executable runtime validator`, + ); + } + + const initializeResult = { + protocolVersion: 1, + runtimeVersion: "0.1.0", + stability: "not_delivered", + capabilities: { + sessionCreate: true, + sessionCreateLifetime: "connection", + query: true, + queryCancel: true, + sessionClose: true, + eventStream: true, + structuredOutput: false, + usage: false, + customTools: false, + permissionCallbacks: false, + hooks: false, + mcpConfiguration: false, + prestartedTransport: false, + }, + }; + assert.equal(validators.isInitializeResult(initializeResult), true); + assert.equal( + validators.isInitializeResult({ ...initializeResult, unexpected: true }), + false, + ); + assert.equal( + validators.isInitializeResult({ + ...initializeResult, + capabilities: { + ...initializeResult.capabilities, + eventStream: "yes", + }, + }), + false, + ); + + assert.equal( + validators.isQueryEventParams({ + queryId: "query_1", + sessionId: "session_1", + turnId: "turn_1", + operationId: "operation_1", + sequence: 1, + event: { type: "assistant_text_delta", text: "hello" }, + }), + true, + ); + assert.equal( + validators.isQueryEventParams({ + queryId: "query_1", + sessionId: "session_1", + turnId: "turn_1", + operationId: "operation_1", + sequence: 1, + }), + false, + ); + + assert.equal( + validators.isSessionCreateResult({ + sessionId: "session_1", + sessionName: "Main", + agent: "agentic", + lifetime: "connection", + executionTarget: { + kind: "managedWorktree", + worktreeId: "worktree_1", + rootPath: "D:/workspace/worktrees/worktree_1", + lifecycle: "managed", + }, + }), + true, + ); + assert.equal( + validators.isSessionCreateResult({ + sessionId: "session_1", + sessionName: "Main", + agent: "agentic", + lifetime: "connection", + executionTarget: { + kind: "managedWorktree", + rootPath: "D:/workspace/worktrees/worktree_1", + lifecycle: "temporary", + }, + }), + false, + ); + + assert.equal(validators.isShutdownParams({}), true); + assert.equal(validators.isShutdownParams({ reason: "later" }), false); +}); diff --git a/sdk/typescript/scripts/runtime-validator-generator.mjs b/sdk/typescript/scripts/runtime-validator-generator.mjs new file mode 100644 index 0000000000..f2cb93feee --- /dev/null +++ b/sdk/typescript/scripts/runtime-validator-generator.mjs @@ -0,0 +1,235 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import ts from "typescript"; + +export async function generateRuntimeValidators( + directory, + { typeImportPath = "./index.js" } = {}, +) { + const aliases = await readAliases(directory); + const names = [...aliases.keys()].sort(); + + for (const [name, declaration] of aliases) { + validateSupportedType(declaration.type, aliases, name); + } + + const header = [ + "// GENERATED CODE! DO NOT MODIFY BY HAND!", + "// Source: bitfun-sdk-host Rust protocol types via ts-rs.", + "", + `import type { ${names.join(", ")} } from ${JSON.stringify(typeImportPath)};`, + "", + ]; + const functions = names.flatMap((name) => { + const declaration = aliases.get(name); + const expression = emitTypeCheck(declaration.type, "value", aliases, name); + return [ + `export function is${name}(value: unknown): value is ${name} {`, + ` return ${expression};`, + "}", + "", + ]; + }); + const helpers = [ + "function isRecord(value: unknown): value is Record {", + ' return typeof value === "object" && value !== null && !Array.isArray(value);', + "}", + "", + "function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean {", + " const keys = Object.keys(value);", + " return keys.length <= allowed.length && keys.every((key) => allowed.includes(key));", + "}", + "", + ]; + + return [...header, ...functions, ...helpers].join("\n"); +} + +async function readAliases(directory) { + const files = (await readdir(directory)) + .filter( + (file) => + file.endsWith(".ts") && + file !== "index.ts" && + file !== "validators.ts", + ) + .sort(); + const aliases = new Map(); + + for (const file of files) { + const sourceText = await readFile(join(directory, file), "utf8"); + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + if (source.parseDiagnostics.length > 0) { + throw new Error(`cannot parse generated wire type ${file}`); + } + const unexpected = source.statements.find( + (statement) => + !ts.isImportDeclaration(statement) && + !ts.isTypeAliasDeclaration(statement), + ); + if (unexpected !== undefined) { + throw new Error( + `generated wire file ${file} contains unsupported declaration ${ts.SyntaxKind[unexpected.kind]}`, + ); + } + const declarations = source.statements.filter(ts.isTypeAliasDeclaration); + if (declarations.length !== 1) { + throw new Error( + `generated wire file ${file} must contain exactly one type alias`, + ); + } + const declaration = declarations[0]; + const expectedName = file.slice(0, -3); + if (declaration.name.text !== expectedName) { + throw new Error( + `generated wire file ${file} must declare ${expectedName}`, + ); + } + if ( + !declaration.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ) + ) { + throw new Error(`generated wire type ${expectedName} must be exported`); + } + aliases.set(expectedName, declaration); + } + return aliases; +} + +function validateSupportedType(node, aliases, owner) { + emitTypeCheck(node, "value", aliases, owner); +} + +function emitTypeCheck(node, value, aliases, owner) { + if (ts.isParenthesizedTypeNode(node)) { + return emitTypeCheck(node.type, value, aliases, owner); + } + if (ts.isUnionTypeNode(node)) { + return `(${node.types + .map((member) => emitTypeCheck(member, value, aliases, owner)) + .join(" || ")})`; + } + if (ts.isLiteralTypeNode(node)) { + if (node.literal.kind === ts.SyntaxKind.NullKeyword) { + return `${value} === null`; + } + if ( + ts.isStringLiteral(node.literal) || + ts.isNumericLiteral(node.literal) + ) { + return `${value} === ${JSON.stringify( + ts.isStringLiteral(node.literal) + ? node.literal.text + : Number(node.literal.text), + )}`; + } + if (node.literal.kind === ts.SyntaxKind.TrueKeyword) { + return `${value} === true`; + } + if (node.literal.kind === ts.SyntaxKind.FalseKeyword) { + return `${value} === false`; + } + throw unsupported(owner, node, "literal"); + } + if (node.kind === ts.SyntaxKind.StringKeyword) { + return `typeof ${value} === "string"`; + } + if (node.kind === ts.SyntaxKind.NumberKeyword) { + return `(typeof ${value} === "number" && Number.isFinite(${value}))`; + } + if (node.kind === ts.SyntaxKind.BooleanKeyword) { + return `typeof ${value} === "boolean"`; + } + if (ts.isArrayTypeNode(node)) { + const item = emitTypeCheck(node.elementType, "item", aliases, owner); + return `(Array.isArray(${value}) && ${value}.every((item: unknown) => ${item}))`; + } + if (ts.isTypeLiteralNode(node)) { + return emitObjectCheck(node, value, aliases, owner); + } + if (ts.isTypeReferenceNode(node)) { + if (!ts.isIdentifier(node.typeName)) { + throw unsupported(owner, node, "qualified type reference"); + } + const name = node.typeName.text; + if (aliases.has(name)) { + if (node.typeArguments !== undefined) { + throw unsupported(owner, node, `generic wire type ${name}`); + } + return `is${name}(${value})`; + } + if (name === "Array" || name === "ReadonlyArray") { + if (node.typeArguments?.length !== 1) { + throw unsupported(owner, node, `${name} arity`); + } + const item = emitTypeCheck(node.typeArguments[0], "item", aliases, owner); + return `(Array.isArray(${value}) && ${value}.every((item: unknown) => ${item}))`; + } + if (name === "Record" && isEmptyRecord(node)) { + return `(isRecord(${value}) && Object.keys(${value}).length === 0)`; + } + throw new Error( + `${owner}: unsupported type reference ${name}; runtime validator generation is fail-closed`, + ); + } + throw unsupported(owner, node, ts.SyntaxKind[node.kind] ?? "unknown syntax"); +} + +function emitObjectCheck(node, value, aliases, owner) { + const properties = []; + for (const member of node.members) { + if (!ts.isPropertySignature(member) || member.type === undefined) { + throw unsupported(owner, member, "object member"); + } + properties.push({ + name: propertyName(member.name, owner), + optional: member.questionToken !== undefined, + type: member.type, + }); + } + const allowed = JSON.stringify(properties.map(({ name }) => name)); + const checks = properties.map(({ name, optional, type }) => { + const access = `${value}[${JSON.stringify(name)}]`; + const check = emitTypeCheck(type, access, aliases, owner); + return optional ? `(${access} === undefined || ${check})` : check; + }); + return [ + `(isRecord(${value})`, + `hasOnlyKeys(${value}, ${allowed})`, + ...checks, + ].join(" && ") + ")"; +} + +function propertyName(node, owner) { + if (ts.isIdentifier(node) || ts.isStringLiteral(node)) { + return node.text; + } + if (ts.isNumericLiteral(node)) { + return node.text; + } + throw unsupported(owner, node, "computed property name"); +} + +function isEmptyRecord(node) { + if (node.typeArguments?.length !== 2) { + return false; + } + return ( + node.typeArguments[0].kind === ts.SyntaxKind.SymbolKeyword && + node.typeArguments[1].kind === ts.SyntaxKind.NeverKeyword + ); +} + +function unsupported(owner, node, detail) { + return new Error( + `${owner}: unsupported ${detail} (${ts.SyntaxKind[node.kind] ?? node.kind}); runtime validator generation is fail-closed`, + ); +} diff --git a/sdk/typescript/scripts/runtime-validator-generator.test.mjs b/sdk/typescript/scripts/runtime-validator-generator.test.mjs new file mode 100644 index 0000000000..2dcb6aa907 --- /dev/null +++ b/sdk/typescript/scripts/runtime-validator-generator.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import ts from "typescript"; + +import { generateRuntimeValidators } from "./runtime-validator-generator.mjs"; + +test("generated validators enforce nested ts-rs wire structures", async () => { + const directory = await mkdtemp(join(tmpdir(), "bitfun-wire-validator-")); + try { + await Promise.all([ + writeFile(join(directory, "Kind.ts"), 'export type Kind = "local" | "remote";\n'), + writeFile( + join(directory, "Child.ts"), + "export type Child = { note?: string | null, tags: Array };\n", + ), + writeFile( + join(directory, "Envelope.ts"), + [ + 'import type { Child } from "./Child";', + 'import type { Kind } from "./Kind";', + "export type Envelope = { kind: Kind, child?: Child, empty: Record };", + "", + ].join("\n"), + ), + ]); + + const validators = await loadValidators(directory); + + assert.equal( + validators.isEnvelope({ + kind: "local", + child: { note: null, tags: ["one", "two"] }, + empty: {}, + }), + true, + ); + assert.equal( + validators.isEnvelope({ + kind: "other", + child: { tags: [] }, + empty: {}, + }), + false, + ); + assert.equal( + validators.isEnvelope({ + kind: "remote", + child: { tags: ["ok", 2] }, + empty: {}, + }), + false, + ); + assert.equal( + validators.isEnvelope({ + kind: "remote", + child: { tags: [], unexpected: true }, + empty: {}, + }), + false, + ); + assert.equal( + validators.isEnvelope({ kind: "remote", empty: {}, unexpected: true }), + false, + ); + assert.equal( + validators.isEnvelope({ kind: "remote", empty: { unexpected: true } }), + false, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("generated validators reject missing required fields", async () => { + const directory = await mkdtemp(join(tmpdir(), "bitfun-wire-validator-")); + try { + await writeFile( + join(directory, "Required.ts"), + "export type Required = { required: string, optional?: string };\n", + ); + + const validators = await loadValidators(directory); + + assert.equal(validators.isRequired({ required: "present" }), true); + assert.equal(validators.isRequired({ optional: "present" }), false); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("generation fails closed for unsupported type syntax", async () => { + const directory = await mkdtemp(join(tmpdir(), "bitfun-wire-validator-")); + try { + await writeFile( + join(directory, "Unsupported.ts"), + "export type Unsupported = { createdAt: Date };\n", + ); + + await assert.rejects( + generateRuntimeValidators(directory), + /unsupported type reference Date/i, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("generation rejects declarations outside the ts-rs type surface", async () => { + const directory = await mkdtemp(join(tmpdir(), "bitfun-wire-validator-")); + try { + await writeFile( + join(directory, "Unexpected.ts"), + [ + "export type Unexpected = { value: string };", + "export const runtimeValue = true;", + "", + ].join("\n"), + ); + + await assert.rejects( + generateRuntimeValidators(directory), + /unsupported declaration/i, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +async function loadValidators(directory) { + const source = await generateRuntimeValidators(directory); + const javascript = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + return import( + `data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}` + ); +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 0000000000..e73ef997e5 --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,154 @@ +import type { InitializeResult, QueryStartParams, QueryStartResult } from "./internal/wire/index.js"; +import type { JsonRpcConnection } from "./internal/json-rpc.js"; +import { Query } from "./query.js"; +import { Session, Sessions } from "./session.js"; +import type { AgentCapabilities, AgentClientOptions, QueryInput } from "./types.js"; + +export class AgentClient { + readonly #connection: JsonRpcConnection; + readonly #options: AgentClientOptions; + readonly capabilities: AgentCapabilities; + readonly sessions: Sessions; + readonly #queries = new Set(); + readonly #ownedSessions = new Set(); + #closed = false; + #closePromise?: Promise; + + static async start(options: AgentClientOptions): Promise { + const hostPath = options.hostPath ?? process.env.BITFUN_SDK_HOST_PATH; + if (hostPath === undefined || hostPath.length === 0) { + const { SdkError } = await import("./errors.js"); + throw new SdkError("SDK Host executable is unavailable", { + code: "not_found", + stage: "initialize", + retryable: false, + correlationId: "local:host_start", + outcomeCertainty: "not_started", + }); + } + const [{ createAgentClient }, { startManagedHost }] = await Promise.all([ + import("./internal/client.js"), + import("./internal/managed-host.js"), + ]); + const transport = await startManagedHost({ + executable: hostPath, + cwd: options.cwd, + }); + try { + return await createAgentClient(transport, options); + } catch (error) { + await transport.close(); + throw error; + } + } + + private constructor( + connection: JsonRpcConnection, + options: AgentClientOptions, + initialized: InitializeResult, + ) { + this.#connection = connection; + this.#options = options; + this.capabilities = Object.freeze({ + query: initialized.capabilities.query, + sessions: initialized.capabilities.sessionCreate, + cancellation: initialized.capabilities.queryCancel, + }); + this.sessions = Sessions.forClient( + connection, + options.cwd, + (query) => this.#trackQuery(query), + (session) => this.#trackSession(session), + () => this.#ensureOpen(), + ); + } + + /** @internal */ + static create( + connection: JsonRpcConnection, + options: AgentClientOptions, + initialized: InitializeResult, + ): AgentClient { + return new AgentClient(connection, options, initialized); + } + + async query(input: QueryInput): Promise { + this.#ensureOpen(); + const params: QueryStartParams = { + prompt: input.prompt, + sessionId: null, + sessionName: null, + agent: input.agent ?? null, + cwd: this.#options.cwd, + model: input.model ?? null, + }; + const started = await this.#connection.request( + "query/start", + params, + ); + if (!started.accepted) { + throw new Error("SDK Host did not accept the Query"); + } + return this.#trackQuery(Query.create(this.#connection, started)); + } + + close(): Promise { + this.#closePromise ??= this.#closeOwnedResources(); + return this.#closePromise; + } + + async #closeOwnedResources(): Promise { + this.#closed = true; + const querySettlements = await Promise.allSettled( + [...this.#queries].map((query) => query.close()), + ); + const sessionSettlements = await Promise.allSettled( + [...this.#ownedSessions].map((session) => session.close()), + ); + let shutdownError: unknown; + try { + await this.#connection.shutdown(); + } catch (error) { + shutdownError = error; + } + const cleanupFailure = [...querySettlements, ...sessionSettlements].find( + (settlement): settlement is PromiseRejectedResult => + settlement.status === "rejected", + ); + if (cleanupFailure !== undefined) { + throw cleanupFailure.reason; + } + if (shutdownError !== undefined) { + throw shutdownError; + } + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + #ensureOpen(): void { + if (this.#closed) { + throw new Error("AgentClient is closed"); + } + } + + #trackQuery(query: Query): Query { + this.#queries.add(query); + query.onClosed(() => this.#queries.delete(query)); + void query.result().then( + () => { + if (!query.ownsSession) { + this.#queries.delete(query); + } + }, + () => this.#queries.delete(query), + ); + return query; + } + + #trackSession(session: Session): void { + this.#ownedSessions.add(session); + session.onClosed(() => this.#ownedSessions.delete(session)); + } +} diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts new file mode 100644 index 0000000000..30864409bb --- /dev/null +++ b/sdk/typescript/src/errors.ts @@ -0,0 +1,40 @@ +import type { + OutcomeCertainty, + RecoveryAction, + SdkErrorCode, + SdkErrorDetails, + SdkErrorStage, +} from "./types.js"; + +export class SdkError extends Error { + readonly code: SdkErrorCode; + readonly stage: SdkErrorStage; + readonly retryable: boolean; + readonly correlationId: string; + readonly operationId?: string; + readonly causationId?: string; + readonly outcomeCertainty: OutcomeCertainty; + readonly recovery?: RecoveryAction; + + constructor(message: string, data: SdkErrorDetails, options?: ErrorOptions) { + super(message, options); + this.name = "SdkError"; + this.code = data.code; + this.stage = data.stage; + this.retryable = data.retryable; + this.correlationId = data.correlationId; + this.operationId = data.operationId ?? undefined; + this.causationId = data.causationId ?? undefined; + this.outcomeCertainty = data.outcomeCertainty; + this.recovery = data.recovery ?? undefined; + } +} + +/** @internal */ +export function isConnectionUnusableError(error: unknown): error is SdkError { + return ( + error instanceof SdkError && + error.outcomeCertainty === "unknown" && + error.recovery === "restart_host" + ); +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000000..c3d6901c6b --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,23 @@ +export { AgentClient } from "./client.js"; +export { SdkError } from "./errors.js"; +export { Query } from "./query.js"; +export { Session, Sessions } from "./session.js"; +export type { + AgentCapabilities, + AgentClientOptions, + AssistantTextDelta, + OutcomeCertainty, + QueryInput, + QueryStreamItem, + RecoveryAction, + Result, + ResultError, + ResultStatus, + SdkErrorCode, + SdkErrorDetails, + SdkErrorStage, + SessionCreateInput, + SessionLifetime, + Turn, + TurnInput, +} from "./types.js"; diff --git a/sdk/typescript/src/internal/client.ts b/sdk/typescript/src/internal/client.ts new file mode 100644 index 0000000000..4199e3c539 --- /dev/null +++ b/sdk/typescript/src/internal/client.ts @@ -0,0 +1,32 @@ +import { AgentClient } from "../client.js"; +import type { AgentClientOptions } from "../types.js"; +import { JsonRpcConnection } from "./json-rpc.js"; +import type { HostTransport } from "./transport.js"; +import type { InitializeParams, InitializeResult } from "./wire/index.js"; + +const PROTOCOL_VERSION = 1; +const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000; + +export async function createAgentClient( + transport: HostTransport, + options: AgentClientOptions, +): Promise { + const connection = new JsonRpcConnection(transport); + const params: InitializeParams = { + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "@bitfun/agent-sdk", version: "0.0.0" }, + capabilities: { serverNotifications: true }, + }; + const initialized = await connection.request( + "initialize", + params, + options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS, + ); + if (initialized.protocolVersion !== PROTOCOL_VERSION) { + await connection.shutdown(); + throw new Error( + `SDK Host selected protocol ${initialized.protocolVersion}; expected ${PROTOCOL_VERSION}`, + ); + } + return AgentClient.create(connection, options, initialized); +} diff --git a/sdk/typescript/src/internal/deadline.ts b/sdk/typescript/src/internal/deadline.ts new file mode 100644 index 0000000000..e59ccd2605 --- /dev/null +++ b/sdk/typescript/src/internal/deadline.ts @@ -0,0 +1,17 @@ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + timeoutError: () => Error, +): Promise { + let timer: ReturnType | undefined; + const expired = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(timeoutError()), timeoutMs); + }); + try { + return await Promise.race([promise, expired]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} diff --git a/sdk/typescript/src/internal/json-rpc.ts b/sdk/typescript/src/internal/json-rpc.ts new file mode 100644 index 0000000000..0ca19f1c51 --- /dev/null +++ b/sdk/typescript/src/internal/json-rpc.ts @@ -0,0 +1,307 @@ +import { + JsonRpcLimitError, + JsonRpcPeer, + JsonRpcProtocolError, + JsonRpcRemoteError, + JsonRpcTimeoutError, + JsonRpcTransportError, + type JsonRpcLimits, + type JsonRpcNotification, +} from "../../../../src/crates/adapters/transport/typescript/src/index.js"; + +import { SdkError } from "../errors.js"; +import type { SdkErrorStage } from "../types.js"; +import { withTimeout } from "./deadline.js"; +import type { HostTransport } from "./transport.js"; +import { HostMessageTransport } from "./transport.js"; +import { + isErrorData, + type QueryNotification, + validateQueryNotification, + validateResponseResult, +} from "./wire-validation.js"; + +interface QuerySubscriber { + notification(value: QueryNotification): void; + failure(error: SdkError): void; +} + +interface JsonRpcConnectionOptions { + requestTimeoutMs?: number; + limits?: Partial; +} + +const DEFAULT_LIMITS: JsonRpcLimits = { + maxMessageBytes: 1024 * 1024, + maxPendingRequests: 128, + maxPendingBytes: 8 * 1024 * 1024, + maxOutboundBytes: 2 * 1024 * 1024, +}; + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + +export class JsonRpcConnection { + readonly #peer: JsonRpcPeer; + readonly #queryHandlers = new Map(); + readonly #backlog = new Map(); + #closed = false; + #transportClosePromise?: Promise; + + constructor( + transport: HostTransport, + options: JsonRpcConnectionOptions = {}, + ) { + const limits: JsonRpcLimits = { + ...DEFAULT_LIMITS, + ...options.limits, + }; + let nextRequestId = 1; + this.#peer = new JsonRpcPeer( + new HostMessageTransport(transport, limits.maxMessageBytes), + { + createRequestId: () => nextRequestId++, + requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + limits, + onNotificationError: (error) => { + this.#fail( + processLost("SDK Host notification violates the protocol", error), + ); + }, + }, + ); + this.#peer.onNotification((notification) => { + this.#handleNotification(notification); + }); + this.#peer.onFailure((error) => { + this.#handlePeerFailure(error); + }); + } + + async request( + method: string, + params: object, + timeoutMs?: number, + ): Promise { + if (this.#closed) { + throw processLost("SDK Host connection is closed"); + } + let result: unknown; + try { + result = await this.#peer.request(method, params, timeoutMs); + } catch (error) { + if (error instanceof JsonRpcRemoteError) { + if (!isErrorData(error.data)) { + const protocolError = processLost( + "SDK Host returned an invalid error contract", + error, + ); + await this.abort(protocolError); + throw protocolError; + } + throw new SdkError(error.message, error.data, { cause: error }); + } + if (error instanceof JsonRpcLimitError) { + throw requestOverloaded(method, error); + } + if (error instanceof JsonRpcTimeoutError) { + const timeoutError = requestTimeout(method, error); + await this.abort(timeoutError); + throw timeoutError; + } + if ( + error instanceof JsonRpcProtocolError || + error instanceof JsonRpcTransportError + ) { + throw processLost("SDK Host connection failed", error); + } + throw error; + } + + try { + return validateResponseResult(method, result) as T; + } catch (error) { + const protocolError = processLost( + "SDK Host returned an invalid method result", + error, + ); + await this.abort(protocolError); + throw protocolError; + } + } + + subscribeQuery( + queryId: string, + handler: (value: QueryNotification) => void, + onFailure: (error: SdkError) => void, + ): () => void { + if (this.#queryHandlers.has(queryId)) { + throw new Error(`Query ${queryId} already has a stream owner`); + } + this.#queryHandlers.set(queryId, { + notification: handler, + failure: onFailure, + }); + for (const notification of this.#backlog.get(queryId) ?? []) { + handler(notification); + } + this.#backlog.delete(queryId); + return () => { + this.#queryHandlers.delete(queryId); + this.#backlog.delete(queryId); + }; + } + + async shutdown(timeoutMs = 12_000): Promise { + if (this.#closed) { + await this.#closeTransport(); + return; + } + try { + const timeoutError = cleanupTimeout( + "SDK Host did not acknowledge shutdown before its cleanup deadline", + "shutdown", + "local:shutdown_timeout", + ); + try { + await withTimeout(this.request("shutdown", {}), timeoutMs, () => timeoutError); + } catch (error) { + if (error === timeoutError) { + this.#fail(timeoutError); + } + throw error; + } + } finally { + this.#closed = true; + await this.#closeTransport(); + } + } + + /** @internal */ + abort(error: SdkError): Promise { + this.#fail(error); + return this.#closeTransport(); + } + + #handleNotification(notification: JsonRpcNotification): void { + if ( + notification.method !== "query/event" && + notification.method !== "query/result" + ) { + throw new Error("Unsupported SDK Host notification"); + } + const validated = validateQueryNotification({ + jsonrpc: "2.0", + method: notification.method, + params: notification.params, + }); + const handler = this.#queryHandlers.get(validated.params.queryId); + if (handler !== undefined) { + handler.notification(validated); + return; + } + const backlog = this.#backlog.get(validated.params.queryId) ?? []; + if (backlog.length >= 16) { + throw new Error("SDK Host sent too many events before Query registration"); + } + backlog.push(validated); + this.#backlog.set(validated.params.queryId, backlog); + } + + #handlePeerFailure(error: Error): void { + if (this.#closed) { + return; + } + this.#fail( + error instanceof SdkError + ? error + : processLost("SDK Host connection was lost", error), + ); + } + + #fail(error: SdkError): void { + if (this.#closed) { + return; + } + this.#closed = true; + for (const subscriber of this.#queryHandlers.values()) { + subscriber.failure(error); + } + this.#queryHandlers.clear(); + this.#backlog.clear(); + void this.#peer.abort(error); + } + + #closeTransport(): Promise { + this.#transportClosePromise ??= this.#peer.close(); + return this.#transportClosePromise; + } +} + +function requestStage(method: string): SdkErrorStage { + if (method === "initialize") { + return "initialize"; + } + if (method.startsWith("query/")) { + return "query"; + } + if (method.startsWith("session/")) { + return "session"; + } + if (method === "shutdown") { + return "shutdown"; + } + return "protocol"; +} + +function requestOverloaded(method: string, cause: JsonRpcLimitError): SdkError { + return new SdkError( + "SDK Host request capacity was exceeded before the request was sent", + { + code: "overloaded", + stage: requestStage(method), + retryable: true, + correlationId: `local:${cause.limit}`, + outcomeCertainty: "not_started", + }, + { cause }, + ); +} + +function requestTimeout(method: string, cause: JsonRpcTimeoutError): SdkError { + return new SdkError(`SDK Host request timed out: ${method}`, { + code: "timeout", + stage: requestStage(method), + retryable: false, + correlationId: `local:request_timeout:${String(cause.id)}`, + outcomeCertainty: "unknown", + recovery: "restart_host", + }, { cause }); +} + +function processLost(message: string, cause?: unknown): SdkError { + return new SdkError(message, { + code: "process_lost", + stage: "protocol", + retryable: false, + correlationId: "local:process_lost", + outcomeCertainty: "unknown", + recovery: "restart_host", + }, cause === undefined ? undefined : { cause }); +} + +function cleanupTimeout( + message: string, + stage: "query" | "session" | "shutdown", + correlationId: string, + operationId?: string, +): SdkError { + return new SdkError(message, { + code: "cleanup_required", + stage, + retryable: false, + correlationId, + operationId, + outcomeCertainty: "unknown", + recovery: "restart_host", + }); +} diff --git a/sdk/typescript/src/internal/managed-host.ts b/sdk/typescript/src/internal/managed-host.ts new file mode 100644 index 0000000000..78b5f40a4e --- /dev/null +++ b/sdk/typescript/src/internal/managed-host.ts @@ -0,0 +1,222 @@ +import { spawn } from "node:child_process"; + +import { SdkError } from "../errors.js"; +import type { HostTransport } from "./transport.js"; + +export interface ManagedHostOptions { + executable: string; + args?: readonly string[]; + cwd: string; + gracefulShutdownMs?: number; + forceKillTimeoutMs?: number; +} + +export interface ManagedHostTransport extends HostTransport { + exitCode(): number | null; + hasExited(): boolean; +} + +export async function startManagedHost( + options: ManagedHostOptions, +): Promise { + const child = spawn(options.executable, [...(options.args ?? [])], { + cwd: options.cwd, + env: process.env, + shell: false, + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", () => { + // Drain diagnostics so a verbose Host cannot block on a full stderr pipe. + }); + + try { + await new Promise((resolve, reject) => { + const onSpawn = (): void => { + child.off("error", onError); + resolve(); + }; + const onError = (error: Error): void => { + child.off("spawn", onSpawn); + reject(error); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); + } catch (cause) { + throw hostStartError(options.executable, cause); + } + + child.on("error", (error) => { + child.stdout.destroy(error); + }); + + let observedExitCode: number | null = null; + let exitObserved = false; + const exited = new Promise((resolve) => { + child.once("exit", (code) => { + observedExitCode = code; + exitObserved = true; + resolve(); + }); + }); + let closePromise: Promise | undefined; + + return { + readable: child.stdout, + writable: child.stdin, + close: () => { + closePromise ??= closeChild(); + return closePromise; + }, + exitCode: () => observedExitCode ?? child.exitCode, + hasExited: () => exitObserved, + }; + + async function closeChild(): Promise { + child.stdin.end(); + const gracefulExit = await settlesWithin( + exited, + options.gracefulShutdownMs ?? 5_000, + ); + const forceKillTimeoutMs = options.forceKillTimeoutMs ?? 2_000; + if (process.platform !== "win32") { + await forceKillTree(child.pid, forceKillTimeoutMs); + if (!gracefulExit && !(await settlesWithin(exited, forceKillTimeoutMs))) { + throw processTreeCleanupError("direct Host exit was not observed"); + } + return; + } + if (gracefulExit) { + return; + } + await forceKillTree(child.pid, forceKillTimeoutMs); + if (!(await settlesWithin(exited, forceKillTimeoutMs))) { + throw new SdkError("SDK Host process tree could not be reclaimed", { + code: "cleanup_required", + stage: "shutdown", + retryable: false, + correlationId: "local:host_process_tree", + outcomeCertainty: "unknown", + recovery: "restart_host", + }); + } + } +} + +/** @internal */ +export async function forceKillTree( + pid: number | undefined, + timeoutMs: number, +): Promise { + if (pid === undefined) { + throw new SdkError("SDK Host process id is unavailable during cleanup", { + code: "cleanup_required", + stage: "shutdown", + retryable: false, + correlationId: "local:host_process_tree", + outcomeCertainty: "unknown", + recovery: "restart_host", + }); + } + if (process.platform !== "win32") { + try { + process.kill(-pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") { + throw error; + } + } + if (!(await processGroupExited(pid, timeoutMs))) { + throw processTreeCleanupError("Unix process group remained alive after SIGKILL"); + } + return; + } + + const taskkill = spawn("taskkill.exe", ["/pid", String(pid), "/t", "/f"], { + shell: false, + stdio: "ignore", + windowsHide: true, + }); + const completed = new Promise((resolve, reject) => { + taskkill.once("error", reject); + taskkill.once("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject(processTreeCleanupError(`taskkill exited with status ${String(code)}`)); + } + }); + }); + if (!(await settlesWithin(completed, timeoutMs))) { + taskkill.kill(); + throw new SdkError("Timed out while terminating the SDK Host process tree", { + code: "cleanup_required", + stage: "shutdown", + retryable: false, + correlationId: "local:host_process_tree", + outcomeCertainty: "unknown", + recovery: "restart_host", + }); + } +} + +async function processGroupExited(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + try { + process.kill(-pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") { + return true; + } + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } while (Date.now() < deadline); + return false; +} + +function processTreeCleanupError(message: string): SdkError { + return new SdkError(`SDK Host process tree could not be reclaimed: ${message}`, { + code: "cleanup_required", + stage: "shutdown", + retryable: false, + correlationId: "local:host_process_tree", + outcomeCertainty: "unknown", + recovery: "restart_host", + }); +} + +async function settlesWithin( + promise: Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + }); + const settled = promise.then(() => true as const); + const result = await Promise.race([settled, timedOut]); + if (timeout !== undefined) { + clearTimeout(timeout); + } + return result; +} + +function hostStartError(executable: string, cause: unknown): SdkError { + return new SdkError( + `SDK Host executable was not found: ${executable}`, + { + code: "not_found", + stage: "initialize", + retryable: false, + correlationId: "local:host_start", + outcomeCertainty: "not_started", + }, + { cause }, + ); +} diff --git a/sdk/typescript/src/internal/transport.ts b/sdk/typescript/src/internal/transport.ts new file mode 100644 index 0000000000..125988b342 --- /dev/null +++ b/sdk/typescript/src/internal/transport.ts @@ -0,0 +1,113 @@ +import type { Readable, Writable } from "node:stream"; +import { StringDecoder } from "node:string_decoder"; + +import type { + MessageTransport, + MessageTransportObserver, +} from "../../../../src/crates/adapters/transport/typescript/src/index.js"; + +export interface HostTransport { + readable: Readable; + writable: Writable; + close(): Promise; +} + +/** SDK-owned newline framing over a managed Host's stdio carrier. */ +export class HostMessageTransport implements MessageTransport { + readonly #host: HostTransport; + readonly #maxLineBytes: number; + #observer?: MessageTransportObserver; + #closePromise?: Promise; + + constructor(host: HostTransport, maxLineBytes: number) { + this.#host = host; + this.#maxLineBytes = maxLineBytes; + } + + subscribe(observer: MessageTransportObserver): () => void { + if (this.#observer !== undefined) { + throw new Error("SDK Host transport already has a message owner"); + } + this.#observer = observer; + void this.#readLoop(observer); + return () => { + if (this.#observer === observer) { + this.#observer = undefined; + } + }; + } + + send(message: string): Promise { + return new Promise((resolve, reject) => { + try { + this.#host.writable.write(`${message}\n`, (error) => { + if (error !== null && error !== undefined) { + reject(error); + } else { + resolve(); + } + }); + } catch (error) { + reject(error); + } + }); + } + + close(): Promise { + this.#closePromise ??= this.#host.close(); + return this.#closePromise; + } + + async #readLoop(observer: MessageTransportObserver): Promise { + try { + for await (const line of readBoundedLines( + this.#host.readable, + this.#maxLineBytes, + )) { + if (this.#observer !== observer) { + return; + } + observer.message(line); + } + if (this.#observer === observer) { + observer.close(new Error("SDK Host closed its output stream")); + } + } catch (error) { + if (this.#observer === observer) { + observer.close(error); + } + } + } +} + +async function* readBoundedLines( + readable: Readable, + maxLineBytes: number, +): AsyncGenerator { + const decoder = new StringDecoder("utf8"); + let buffered = ""; + for await (const chunk of readable) { + buffered += + typeof chunk === "string" ? chunk : decoder.write(chunk as Buffer); + let newline = buffered.indexOf("\n"); + while (newline >= 0) { + let line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > maxLineBytes) { + throw new Error("SDK Host response line exceeds the size limit"); + } + if (line.endsWith("\r")) { + line = line.slice(0, -1); + } + yield line; + newline = buffered.indexOf("\n"); + } + if (Buffer.byteLength(buffered, "utf8") > maxLineBytes) { + throw new Error("SDK Host response line exceeds the size limit"); + } + } + buffered += decoder.end(); + if (buffered.length > 0) { + throw new Error("SDK Host output ended with an unterminated response line"); + } +} diff --git a/sdk/typescript/src/internal/wire-validation.ts b/sdk/typescript/src/internal/wire-validation.ts new file mode 100644 index 0000000000..7b390f41dc --- /dev/null +++ b/sdk/typescript/src/internal/wire-validation.ts @@ -0,0 +1,219 @@ +import type { + ErrorData, + InitializeResult, + QueryCancelResult, + QueryEventParams, + QueryResultParams, + QueryStartResult, + SessionCloseResult, + SessionCreateResult, + ShutdownResult, +} from "./wire/index.js"; +import { + isErrorData as isGeneratedErrorData, + isInitializeResult, + isQueryCancelResult, + isQueryEventParams, + isQueryResultParams, + isQueryStartResult, + isSessionCloseResult, + isSessionCreateResult, + isShutdownResult, +} from "./wire-validators.js"; + +// Rust protocol types own field names, shapes, and enum values through the +// generated validators. This module keeps only SDK envelope and semantic rules +// that TypeScript types cannot express. + +export type QueryNotification = + | { jsonrpc: "2.0"; method: "query/event"; params: QueryEventParams } + | { jsonrpc: "2.0"; method: "query/result"; params: QueryResultParams }; + +export function validateResponseResult(method: string, value: unknown): T { + switch (method) { + case "initialize": + return validateInitializeResult(value) as T; + case "session/create": + return validateSessionCreateResult(value) as T; + case "query/start": + return validateQueryStartResult(value) as T; + case "query/cancel": + return validateQueryCancelResult(value) as T; + case "session/close": + return validateSessionCloseResult(value) as T; + case "shutdown": + return validateWireValue(isShutdownResult, value, "shutdown result") as T; + default: + throw new Error(`SDK Host returned a result for unknown method ${method}`); + } +} + +export function validateQueryNotification(value: unknown): QueryNotification { + assertRecord(value, "Query notification"); + assertOnlyKeys(value, ["jsonrpc", "method", "params"], "Query notification"); + if (value.jsonrpc !== "2.0") { + throw new Error("Query notification has an invalid JSON-RPC version"); + } + if (value.method === "query/event") { + return { + jsonrpc: "2.0", + method: "query/event", + params: validateQueryEventParams(value.params), + }; + } + if (value.method === "query/result") { + return { + jsonrpc: "2.0", + method: "query/result", + params: validateQueryResultParams(value.params), + }; + } + throw new Error("SDK Host notification method is unsupported"); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function hasOnlyKeys( + value: Record, + allowed: readonly string[], +): boolean { + const keys = Object.keys(value); + return keys.length <= allowed.length && keys.every((key) => allowed.includes(key)); +} + +export function isErrorData(value: unknown): value is ErrorData { + return isGeneratedErrorData(value); +} + +function validateInitializeResult(value: unknown): InitializeResult { + const result = validateWireValue( + isInitializeResult, + value, + "initialize result", + ); + if (!Number.isSafeInteger(result.protocolVersion)) { + throw new Error("SDK Host initialize protocol version is invalid"); + } + return result; +} + +function validateSessionCreateResult(value: unknown): SessionCreateResult { + const result = validateWireValue( + isSessionCreateResult, + value, + "Session create result", + ); + if (!isNonEmptyString(result.sessionId)) { + throw new Error("SDK Host Session create identity is invalid"); + } + return result; +} + +function validateQueryStartResult(value: unknown): QueryStartResult { + const result = validateWireValue(isQueryStartResult, value, "Query start result"); + if (!hasQueryIdentity(result)) { + throw new Error("SDK Host Query start identity is invalid"); + } + return result; +} + +function validateQueryCancelResult(value: unknown): QueryCancelResult { + const result = validateWireValue( + isQueryCancelResult, + value, + "Query cancel result", + ); + if (!hasQueryIdentity(result)) { + throw new Error("SDK Host Query cancel identity is invalid"); + } + return result; +} + +function validateSessionCloseResult(value: unknown): SessionCloseResult { + const result = validateWireValue( + isSessionCloseResult, + value, + "Session close result", + ); + if (!isNonEmptyString(result.sessionId)) { + throw new Error("SDK Host Session close identity is invalid"); + } + return result; +} + +function validateQueryEventParams(value: unknown): QueryEventParams { + const params = validateWireValue(isQueryEventParams, value, "Query event params"); + if ( + !hasQueryIdentity(params) || + !Number.isSafeInteger(params.sequence) || + params.sequence < 1 + ) { + throw new Error("SDK Host Query event identity or sequence is invalid"); + } + return params; +} + +function validateQueryResultParams(value: unknown): QueryResultParams { + const params = validateWireValue( + isQueryResultParams, + value, + "Query result params", + ); + if (!hasQueryIdentity(params)) { + throw new Error("SDK Host Query result identity is invalid"); + } + if (params.status === "completed" && params.error !== undefined) { + throw new Error("A completed SDK Host Query Result cannot contain an error"); + } + return params; +} + +function validateWireValue( + validator: (value: unknown) => value is T, + value: unknown, + label: string, +): T { + if (!validator(value)) { + throw new Error(`${label} does not match the generated Rust wire contract`); + } + return value; +} + +function hasQueryIdentity(value: { + queryId: string; + sessionId: string; + turnId: string; + operationId: string; +}): boolean { + return ( + isNonEmptyString(value.queryId) && + isNonEmptyString(value.sessionId) && + isNonEmptyString(value.turnId) && + isNonEmptyString(value.operationId) + ); +} + +function assertRecord( + value: unknown, + label: string, +): asserts value is Record { + if (!isRecord(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertOnlyKeys( + value: Record, + allowed: readonly string[], + label: string, +): void { + if (!hasOnlyKeys(value, allowed)) { + throw new Error(`${label} contains unknown fields`); + } +} + +function isNonEmptyString(value: string): boolean { + return value.length > 0; +} diff --git a/sdk/typescript/src/query.ts b/sdk/typescript/src/query.ts new file mode 100644 index 0000000000..2fbe466c51 --- /dev/null +++ b/sdk/typescript/src/query.ts @@ -0,0 +1,364 @@ +import type { JsonRpcConnection } from "./internal/json-rpc.js"; +import { withTimeout } from "./internal/deadline.js"; +import type { + QueryCancelParams, + QueryCancelResult, + QueryEventParams, + QueryResultParams, + QueryStartResult, + SessionCloseParams, + SessionCloseResult, +} from "./internal/wire/index.js"; +import type { QueryStreamItem, Result, ResultError, Turn } from "./types.js"; +import { isConnectionUnusableError, SdkError } from "./errors.js"; + +interface QueueWaiter { + resolve(value: IteratorResult): void; + reject(error: Error): void; +} + +const MAX_BUFFERED_ITEMS = 1_024; +const MAX_BUFFERED_EVENT_BYTES = 1024 * 1024; + +export class Query implements AsyncIterable { + readonly #connection: JsonRpcConnection; + readonly id: string; + readonly operationId: string; + readonly turn: Turn; + readonly #items: QueryStreamItem[] = []; + readonly #waiters: QueueWaiter[] = []; + readonly #resultPromise: Promise; + readonly #resolveResult: (result: Result) => void; + readonly #rejectResult: (error: Error) => void; + readonly #ownsSession: boolean; + readonly #closeTimeoutMs: number; + readonly #closedHandlers = new Set<() => void>(); + #lastSequence = 0; + #terminal = false; + #unsubscribe: () => void = () => {}; + #cancelPromise?: Promise; + #closePromise?: Promise; + #failure?: Error; + #closed = false; + #bufferedBytes = 0; + + /** @internal */ + static create( + connection: JsonRpcConnection, + started: QueryStartResult, + closeTimeoutMs = 7_000, + ): Query { + return new Query(connection, started, closeTimeoutMs); + } + + private constructor( + connection: JsonRpcConnection, + started: QueryStartResult, + closeTimeoutMs: number, + ) { + this.#connection = connection; + this.id = started.queryId; + this.operationId = started.operationId; + this.#ownsSession = started.createdSession; + this.#closeTimeoutMs = closeTimeoutMs; + this.turn = Object.freeze({ + id: started.turnId, + sessionId: started.sessionId, + }); + let resolveResult!: (result: Result) => void; + let rejectResult!: (error: Error) => void; + this.#resultPromise = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + this.#resolveResult = resolveResult; + this.#rejectResult = rejectResult; + this.#unsubscribe = connection.subscribeQuery( + this.id, + (notification) => { + if (notification.method === "query/event") { + this.#onEvent(notification.params); + } else { + this.#onResult(notification.params); + } + }, + (error) => this.#onConnectionFailure(error), + ); + if (this.#terminal) { + this.#unsubscribe(); + } + } + + result(): Promise { + return this.#resultPromise; + } + + cancel(): Promise { + if (this.#terminal) { + return Promise.resolve(); + } + this.#cancelPromise ??= this.#requestCancel(); + return this.#cancelPromise; + } + + close(): Promise { + this.#closePromise ??= this.#closeQuery(); + return this.#closePromise; + } + + /** @internal */ + get ownsSession(): boolean { + return this.#ownsSession; + } + + /** @internal */ + onClosed(handler: () => void): void { + this.#closedHandlers.add(handler); + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => this.#next(), + return: async () => { + await this.close(); + return { done: true, value: undefined }; + }, + }; + } + + #onEvent(params: QueryEventParams): void { + this.#assertIdentity( + params.queryId, + params.sessionId, + params.turnId, + params.operationId, + ); + if (this.#terminal || params.sequence !== this.#lastSequence + 1) { + throw new Error("SDK Host Query event ordering is invalid"); + } + this.#lastSequence = params.sequence; + if (params.event.type === "assistant_text_delta") { + this.#push({ + type: "assistant_text_delta", + queryId: params.queryId, + sessionId: params.sessionId, + turnId: params.turnId, + operationId: params.operationId, + sequence: params.sequence, + text: params.event.text, + }); + } + } + + async #requestCancel(): Promise { + const params: QueryCancelParams = { + queryId: this.id, + sessionId: this.turn.sessionId, + turnId: this.turn.id, + operationId: this.operationId, + }; + const cancelled = await this.#connection.request( + "query/cancel", + params, + ); + if ( + cancelled.queryId !== this.id || + cancelled.sessionId !== this.turn.sessionId || + cancelled.turnId !== this.turn.id || + cancelled.operationId !== this.operationId + ) { + throw new Error("SDK Host returned a different Query from cancellation"); + } + } + + async #closeQuery(): Promise { + const timeoutError = new SdkError( + "SDK Host did not settle Query cleanup before its deadline", + { + code: "cleanup_required", + stage: "query", + retryable: false, + correlationId: `query:${this.id}`, + operationId: this.operationId, + outcomeCertainty: "unknown", + recovery: "restart_host", + }, + ); + try { + await withTimeout( + this.#closeWithinProtocol(), + this.#closeTimeoutMs, + () => timeoutError, + ); + } catch (error) { + if (isConnectionUnusableError(error)) { + await this.#connection.abort(error); + } + throw error; + } + this.#markClosed(); + } + + async #closeWithinProtocol(): Promise { + if (!this.#terminal) { + await this.cancel(); + } + await this.#resultPromise; + if (this.#ownsSession) { + const params: SessionCloseParams = { sessionId: this.turn.sessionId }; + const closed = await this.#connection.request( + "session/close", + params, + ); + if (closed.sessionId !== this.turn.sessionId) { + throw new Error("SDK Host closed a different transient Session"); + } + } + } + + #onResult(params: QueryResultParams): void { + this.#assertIdentity( + params.queryId, + params.sessionId, + params.turnId, + params.operationId, + ); + if (this.#terminal) { + throw new Error("SDK Host emitted more than one terminal Result"); + } + const result: Result = { + type: "result", + queryId: params.queryId, + sessionId: params.sessionId, + turnId: params.turnId, + operationId: params.operationId, + status: params.status, + outputText: params.output.text, + ...(params.error === undefined ? {} : { error: mapResultError(params.error) }), + }; + if (!this.#push(result)) { + return; + } + this.#terminal = true; + this.#resolveResult(result); + this.#unsubscribe(); + while (this.#waiters.length > 0) { + this.#waiters.shift()?.resolve({ done: true, value: undefined }); + } + } + + #assertIdentity( + queryId: string, + sessionId: string, + turnId: string, + operationId: string, + ): void { + if ( + queryId !== this.id || + sessionId !== this.turn.sessionId || + turnId !== this.turn.id || + operationId !== this.operationId + ) { + throw new Error("SDK Host Query identity changed during streaming"); + } + } + + #push(item: QueryStreamItem): boolean { + const waiter = this.#waiters.shift(); + if (waiter !== undefined) { + waiter.resolve({ done: false, value: item }); + return true; + } + const itemBytes = bufferedItemBytes(item); + if ( + this.#items.length >= MAX_BUFFERED_ITEMS || + this.#bufferedBytes + itemBytes > MAX_BUFFERED_EVENT_BYTES + ) { + const error = new SdkError("SDK Host Query event buffer capacity was exceeded", { + code: "overloaded", + stage: "query", + retryable: false, + correlationId: `query:${this.id}`, + operationId: this.operationId, + outcomeCertainty: "unknown", + recovery: "restart_host", + }); + void this.#connection.abort(error); + return false; + } + this.#items.push(item); + this.#bufferedBytes += itemBytes; + return true; + } + + #next(): Promise> { + const item = this.#items.shift(); + if (item !== undefined) { + this.#bufferedBytes -= bufferedItemBytes(item); + return Promise.resolve({ done: false, value: item }); + } + if (this.#failure !== undefined) { + return Promise.reject(this.#failure); + } + if (this.#terminal) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise((resolve, reject) => { + this.#waiters.push({ resolve, reject }); + }); + } + + #onConnectionFailure(error: SdkError): void { + if (this.#terminal) { + return; + } + this.#terminal = true; + this.#failure = error; + this.#rejectResult(error); + this.#unsubscribe(); + while (this.#waiters.length > 0) { + this.#waiters.shift()?.reject(error); + } + } + + #markClosed(): void { + if (this.#closed) { + return; + } + this.#closed = true; + for (const handler of this.#closedHandlers) { + handler(); + } + this.#closedHandlers.clear(); + } +} + +function bufferedItemBytes(item: QueryStreamItem): number { + // Result frames have their own connection-level frame bound. Counting their + // aggregate output again against the event backlog would reject a valid + // Query that has not consumed the same text deltas yet. + return item.type === "assistant_text_delta" + ? Buffer.byteLength(item.text, "utf8") + 256 + : 0; +} + +function mapResultError(error: QueryResultParams["error"]): ResultError { + if (error === undefined) { + throw new Error("Result error is unavailable"); + } + return { + message: error.message, + code: error.data.code, + stage: error.data.stage, + retryable: error.data.retryable, + correlationId: error.data.correlationId, + operationId: error.data.operationId ?? undefined, + causationId: error.data.causationId ?? undefined, + outcomeCertainty: error.data.outcomeCertainty, + recovery: error.data.recovery ?? undefined, + }; +} diff --git a/sdk/typescript/src/session.ts b/sdk/typescript/src/session.ts new file mode 100644 index 0000000000..c6b865e9b7 --- /dev/null +++ b/sdk/typescript/src/session.ts @@ -0,0 +1,177 @@ +import type { + QueryStartParams, + QueryStartResult, + SessionCloseParams, + SessionCreateParams, + SessionCreateResult, +} from "./internal/wire/index.js"; +import type { JsonRpcConnection } from "./internal/json-rpc.js"; +import { withTimeout } from "./internal/deadline.js"; +import { isConnectionUnusableError, SdkError } from "./errors.js"; +import { Query } from "./query.js"; +import type { SessionCreateInput, SessionLifetime, TurnInput } from "./types.js"; + +export class Sessions { + readonly #connection: JsonRpcConnection; + readonly #cwd: string; + readonly #onQuery: (query: Query) => Query; + readonly #onSession: (session: Session) => void; + readonly #ensureClientOpen: () => void; + + /** @internal */ + static forClient( + connection: JsonRpcConnection, + cwd: string, + onQuery: (query: Query) => Query, + onSession: (session: Session) => void, + ensureClientOpen: () => void, + ): Sessions { + return new Sessions( + connection, + cwd, + onQuery, + onSession, + ensureClientOpen, + ); + } + + private constructor( + connection: JsonRpcConnection, + cwd: string, + onQuery: (query: Query) => Query, + onSession: (session: Session) => void, + ensureClientOpen: () => void, + ) { + this.#connection = connection; + this.#cwd = cwd; + this.#onQuery = onQuery; + this.#onSession = onSession; + this.#ensureClientOpen = ensureClientOpen; + } + + async create(input: SessionCreateInput = {}): Promise { + this.#ensureClientOpen(); + const params: SessionCreateParams = { + sessionName: input.sessionName ?? null, + agent: input.agent ?? null, + cwd: this.#cwd, + model: input.model ?? null, + }; + const created = await this.#connection.request( + "session/create", + params, + ); + const session = Session.create(this.#connection, created, this.#onQuery); + this.#onSession(session); + return session; + } +} + +export class Session { + readonly #connection: JsonRpcConnection; + readonly id: string; + readonly name: string; + readonly agent: string; + readonly lifetime: SessionLifetime; + readonly workspacePath?: string; + readonly #onQuery: (query: Query) => Query; + readonly #closeTimeoutMs: number; + readonly #closedHandlers = new Set<() => void>(); + #closed = false; + #closePromise?: Promise; + + /** @internal */ + static create( + connection: JsonRpcConnection, + created: SessionCreateResult, + onQuery: (query: Query) => Query, + closeTimeoutMs = 7_000, + ): Session { + return new Session(connection, created, onQuery, closeTimeoutMs); + } + + private constructor( + connection: JsonRpcConnection, + created: SessionCreateResult, + onQuery: (query: Query) => Query, + closeTimeoutMs: number, + ) { + this.#connection = connection; + this.id = created.sessionId; + this.name = created.sessionName; + this.agent = created.agent; + this.lifetime = created.lifetime; + this.workspacePath = created.workspacePath; + this.#onQuery = onQuery; + this.#closeTimeoutMs = closeTimeoutMs; + } + + async startTurn(input: TurnInput): Promise { + this.#ensureOpen(); + const params: QueryStartParams = { + prompt: input.prompt, + sessionId: this.id, + }; + const started = await this.#connection.request( + "query/start", + params, + ); + if (!started.accepted || started.sessionId !== this.id) { + throw new Error("SDK Host did not accept the Turn for this Session"); + } + return this.#onQuery(Query.create(this.#connection, started)); + } + + close(): Promise { + this.#closePromise ??= this.#closeSession(); + return this.#closePromise; + } + + /** @internal */ + onClosed(handler: () => void): void { + this.#closedHandlers.add(handler); + } + + async #closeSession(): Promise { + this.#closed = true; + const params: SessionCloseParams = { sessionId: this.id }; + const timeoutError = new SdkError( + "SDK Host did not close Session before its cleanup deadline", + { + code: "cleanup_required", + stage: "session", + retryable: false, + correlationId: `session:${this.id}`, + outcomeCertainty: "unknown", + recovery: "restart_host", + }, + ); + try { + await withTimeout( + this.#connection.request("session/close", params), + this.#closeTimeoutMs, + () => timeoutError, + ); + } catch (error) { + if (isConnectionUnusableError(error)) { + await this.#connection.abort(error); + } + throw error; + } finally { + for (const handler of this.#closedHandlers) { + handler(); + } + this.#closedHandlers.clear(); + } + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + #ensureOpen(): void { + if (this.#closed) { + throw new Error("Session is closed"); + } + } +} diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts new file mode 100644 index 0000000000..cd460482c4 --- /dev/null +++ b/sdk/typescript/src/types.ts @@ -0,0 +1,114 @@ +export interface AgentClientOptions { + cwd: string; + /** Native `bitfun-sdk-host` path. Platform packages will provide this later. */ + hostPath?: string; + /** Deadline for the SDK Host initialize handshake. */ + initializeTimeoutMs?: number; +} + +export interface AgentCapabilities { + query: boolean; + sessions: boolean; + cancellation: boolean; +} + +export type SdkErrorCode = + | "invalid_request" + | "not_initialized" + | "already_initialized" + | "version_mismatch" + | "capability_unavailable" + | "not_found" + | "permission_denied" + | "action_required" + | "authentication" + | "rate_limited" + | "provider_quota" + | "provider_billing" + | "provider_unavailable" + | "context_overflow" + | "content_policy" + | "overloaded" + | "timeout" + | "cancelled" + | "process_lost" + | "cleanup_required" + | "internal"; + +export type SdkErrorStage = + | "protocol" + | "initialize" + | "session" + | "query" + | "shutdown"; + +export type OutcomeCertainty = "not_started" | "committed" | "unknown"; + +export type RecoveryAction = + | "initialize" + | "retry" + | "update_sdk" + | "restart_host"; + +export interface SdkErrorDetails { + code: SdkErrorCode; + stage: SdkErrorStage; + retryable: boolean; + correlationId: string; + operationId?: string; + causationId?: string; + outcomeCertainty: OutcomeCertainty; + recovery?: RecoveryAction; +} + +export type SessionLifetime = "connection"; + +export interface QueryInput { + prompt: string; + agent?: string; + model?: string; +} + +export interface SessionCreateInput { + sessionName?: string; + agent?: string; + model?: string; +} + +export interface TurnInput { + prompt: string; +} + +export interface Turn { + readonly id: string; + readonly sessionId: string; +} + +export interface AssistantTextDelta { + type: "assistant_text_delta"; + queryId: string; + sessionId: string; + turnId: string; + operationId: string; + sequence: number; + text: string; +} + +export type ResultStatus = "completed" | "failed" | "cancelled"; + +export interface ResultError extends SdkErrorDetails { + message: string; +} + +export interface Result { + type: "result"; + queryId: string; + sessionId: string; + turnId: string; + operationId: string; + status: ResultStatus; + outputText: string; + error?: ResultError; +} + +export type QueryStreamItem = AssistantTextDelta | Result; diff --git a/sdk/typescript/test/buffer-limits.test.ts b/sdk/typescript/test/buffer-limits.test.ts new file mode 100644 index 0000000000..443137d2b2 --- /dev/null +++ b/sdk/typescript/test/buffer-limits.test.ts @@ -0,0 +1,310 @@ +import assert from "node:assert/strict"; +import { PassThrough, Writable } from "node:stream"; +import test from "node:test"; + +import { SdkError } from "../src/errors.js"; +import { JsonRpcConnection } from "../src/internal/json-rpc.js"; +import type { QueryStartResult } from "../src/internal/wire/index.js"; +import { Query } from "../src/query.js"; + +test("request correlation rejects overload before the pending request count grows", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const connection = createBoundedConnection( + hostToClient, + clientToHost, + { maxPendingRequests: 1 }, + ); + const first = connection.request("query/start", {}); + + try { + await assert.rejects(connection.request("query/start", {}), isLocalOverload); + } finally { + void first.catch(() => {}); + await connection.abort(testCleanupError()); + } +}); + +test("request correlation rejects overload while the carrier remains backpressured", async () => { + const hostToClient = new PassThrough(); + const stalled = new Writable({ + write(_chunk, _encoding, _callback) { + // Deliberately retain the callback: the carrier has not accepted the + // complete message, so MessageTransport.send must remain backpressured. + }, + }); + const connection = createBoundedConnection( + hostToClient, + stalled, + { maxOutboundBytes: 100 }, + ); + const first = connection.request("query/start", {}); + + try { + await assert.rejects(connection.request("query/start", {}), isLocalOverload); + } finally { + void first.catch(() => {}); + await connection.abort(testCleanupError()); + } +}); + +test("a slow Query consumer fails closed before its event queue grows without bound", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const connection = new JsonRpcConnection({ + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + hostToClient.end(); + }, + }); + const started: QueryStartResult = { + queryId: "query-slow-consumer", + sessionId: "session-slow-consumer", + turnId: "turn-slow-consumer", + operationId: "operation-slow-consumer", + accepted: true, + createdSession: false, + sessionLifetime: "connection", + }; + const query = Query.create(connection, started); + + for (let sequence = 1; sequence <= 1_025; sequence += 1) { + write(hostToClient, { + jsonrpc: "2.0", + method: "query/event", + params: { + queryId: started.queryId, + sessionId: started.sessionId, + turnId: started.turnId, + operationId: started.operationId, + sequence, + event: { type: "assistant_text_delta", text: "x" }, + }, + }); + } + write(hostToClient, { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: started.queryId, + sessionId: started.sessionId, + turnId: started.turnId, + operationId: started.operationId, + status: "completed", + output: { text: "x".repeat(1_025) }, + }, + }); + + try { + await assert.rejects(query.result(), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "overloaded"); + assert.equal(error.stage, "query"); + assert.equal(error.outcomeCertainty, "unknown"); + return true; + }); + assert.equal(transportClosed, true); + } finally { + await connection.abort( + new SdkError("test cleanup", { + code: "process_lost", + stage: "protocol", + retryable: false, + correlationId: "test:cleanup", + outcomeCertainty: "unknown", + }), + ); + } +}); + +test("a protocol-sized Result remains available without consuming the event stream", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const connection = new JsonRpcConnection({ + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + hostToClient.end(); + }, + }); + const started: QueryStartResult = { + queryId: "query-result-only", + sessionId: "session-result-only", + turnId: "turn-result-only", + operationId: "operation-result-only", + accepted: true, + createdSession: false, + sessionLifetime: "connection", + }; + const query = Query.create(connection, started); + const output = "x".repeat(512 * 1024); + + write(hostToClient, { + jsonrpc: "2.0", + method: "query/event", + params: { + queryId: started.queryId, + sessionId: started.sessionId, + turnId: started.turnId, + operationId: started.operationId, + sequence: 1, + event: { type: "assistant_text_delta", text: output }, + }, + }); + write(hostToClient, { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: started.queryId, + sessionId: started.sessionId, + turnId: started.turnId, + operationId: started.operationId, + status: "completed", + output: { text: output }, + }, + }); + + try { + const result = await withTestDeadline(query.result()); + assert.equal(result.status, "completed"); + assert.equal(result.outputText.length, output.length); + assert.equal(transportClosed, false); + } finally { + await connection.abort( + new SdkError("test cleanup", { + code: "process_lost", + stage: "protocol", + retryable: false, + correlationId: "test:cleanup", + outcomeCertainty: "unknown", + }), + ); + } +}); + +test("an oversized unterminated Host frame fails the connection before unbounded buffering", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const connection = new JsonRpcConnection({ + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + hostToClient.end(); + }, + }); + const pending = connection.request("initialize", {}); + hostToClient.write("x".repeat(1024 * 1024 + 1)); + + try { + await assert.rejects(withTestDeadline(pending), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "process_lost"); + assert.equal(error.outcomeCertainty, "unknown"); + return true; + }); + assert.equal(transportClosed, true); + } finally { + await connection.abort( + new SdkError("test cleanup", { + code: "process_lost", + stage: "protocol", + retryable: false, + correlationId: "test:cleanup", + outcomeCertainty: "unknown", + }), + ); + } +}); + +function write(stream: PassThrough, value: unknown): void { + stream.write(`${JSON.stringify(value)}\n`); +} + +async function withTestDeadline(promise: Promise): Promise { + let timer: ReturnType | undefined; + const expired = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("frame buffering did not stop")), 250); + }); + try { + return await Promise.race([promise, expired]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +interface TestConnectionLimits { + maxPendingRequests?: number; + maxOutboundBytes?: number; +} + +function createBoundedConnection( + readable: PassThrough, + writable: Writable, + limits: TestConnectionLimits, +): JsonRpcConnection { + const Connection = JsonRpcConnection as unknown as new ( + transport: { + readable: PassThrough; + writable: Writable; + close(): Promise; + }, + options: { + requestTimeoutMs: number; + limits: { + maxMessageBytes: number; + maxPendingRequests: number; + maxPendingBytes: number; + maxOutboundBytes: number; + }; + }, + ) => JsonRpcConnection; + return new Connection( + { + readable, + writable, + close: async () => { + readable.end(); + writable.destroy(); + }, + }, + { + requestTimeoutMs: 1_000, + limits: { + maxMessageBytes: 1_024, + maxPendingRequests: limits.maxPendingRequests ?? 8, + maxPendingBytes: 4_096, + maxOutboundBytes: limits.maxOutboundBytes ?? 4_096, + }, + }, + ); +} + +function isLocalOverload(error: unknown): boolean { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "overloaded"); + assert.equal(error.stage, "query"); + assert.equal(error.outcomeCertainty, "not_started"); + return true; +} + +function testCleanupError(): SdkError { + return new SdkError("test cleanup", { + code: "process_lost", + stage: "protocol", + retryable: false, + correlationId: "test:cleanup", + outcomeCertainty: "unknown", + }); +} diff --git a/sdk/typescript/test/client.test.ts b/sdk/typescript/test/client.test.ts new file mode 100644 index 0000000000..b9b9106aac --- /dev/null +++ b/sdk/typescript/test/client.test.ts @@ -0,0 +1,1016 @@ +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { createInterface } from "node:readline"; +import test from "node:test"; + +import { AgentClient, SdkError } from "../src/index.js"; +import { createAgentClient } from "../src/internal/client.js"; + +test("a Query streams ordered events and returns the Host terminal Result", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const host = runFixtureHost(clientToHost, hostToClient); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + assert.ok(client instanceof AgentClient); + const query = await client.query({ prompt: "hello" }); + assert.equal(query.id, "query-1"); + assert.equal(query.operationId, "operation-1"); + assert.deepEqual(query.turn, { id: "turn-1", sessionId: "session-1" }); + const items = []; + for await (const item of query) { + items.push(item); + } + + assert.deepEqual(items, [ + { + type: "assistant_text_delta", + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + sequence: 1, + text: "fixture result", + }, + { + type: "result", + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + status: "completed", + outputText: "fixture result", + }, + ]); + assert.equal((await query.result()).outputText, "fixture result"); + assert.equal(typeof query[Symbol.asyncDispose], "function"); + + await client.close(); + assert.equal(typeof client[Symbol.asyncDispose], "function"); +}); + +test("an explicit Session starts Turns on the existing client connection", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const methods: string[] = []; + const host = runSessionFixtureHost(clientToHost, hostToClient, methods); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + const session = await client.sessions.create({ agent: "agentic" }); + assert.equal(session.id, "session-explicit"); + assert.equal(session.agent, "agentic"); + + const query = await session.startTurn({ prompt: "continue" }); + assert.equal((await query.result()).outputText, "continued"); + await session.close(); + assert.equal(typeof session[Symbol.asyncDispose], "function"); + await client.close(); + + assert.deepEqual(methods, [ + "initialize", + "session/create", + "query/start", + "session/close", + "shutdown", + ]); +}); + +test("Query cancel and close are idempotent and the Host Result remains authoritative", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let cancelRequests = 0; + const host = runCancelFixtureHost(clientToHost, hostToClient, () => { + cancelRequests += 1; + }); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + const query = await client.query({ prompt: "wait" }); + await Promise.all([query.cancel(), query.cancel()]); + const result = await query.result(); + assert.equal(result.status, "cancelled"); + assert.equal(result.error?.code, "cancelled"); + await query.close(); + assert.equal(cancelRequests, 1); + + await client.close(); +}); + +test("leaving Query iteration early cancels and settles the Turn", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let cancelRequests = 0; + const host = runCancelFixtureHost( + clientToHost, + hostToClient, + () => { + cancelRequests += 1; + }, + true, + ); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + const query = await client.query({ prompt: "stream" }); + for await (const item of query) { + assert.equal(item.type, "assistant_text_delta"); + break; + } + assert.equal(cancelRequests, 1); + assert.equal((await query.result()).status, "cancelled"); + + await client.close(); +}); + +test("Host loss rejects an accepted Query with unknown outcome instead of fabricating a Result", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const host = runProcessLossFixtureHost(clientToHost, hostToClient); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + const query = await client.query({ prompt: "may have side effects" }); + await assert.rejects(query.result(), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "process_lost"); + assert.equal(error.stage, "protocol"); + assert.equal(error.outcomeCertainty, "unknown"); + return true; + }); + await assert.rejects(query[Symbol.asyncIterator]().next(), SdkError); + + await client.close(); + assert.equal(transportClosed, true); +}); + +test("AgentClient.close settles owned Queries before shutting down its connection", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const methods: string[] = []; + const host = runClientCloseFixtureHost(clientToHost, hostToClient, methods); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + const query = await client.query({ prompt: "still running" }); + await client.close(); + assert.equal((await query.result()).status, "cancelled"); + assert.deepEqual(methods, [ + "initialize", + "query/start", + "query/cancel", + "session/close", + "shutdown", + ]); +}); + +test("Host operation errors preserve stable SDK error facts", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const host = runOperationErrorFixtureHost(clientToHost, hostToClient); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + await assert.rejects(client.query({ prompt: "requires auth" }), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "action_required"); + assert.equal(error.stage, "query"); + assert.equal(error.retryable, false); + assert.equal(error.correlationId, "request:query-start"); + assert.equal(error.outcomeCertainty, "not_started"); + assert.equal(error.recovery, "initialize"); + return true; + }); + + await client.close(); +}); + +test("unknown Host error facts fail the protocol closed", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const host = runInvalidErrorFixtureHost(clientToHost, hostToClient); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + await assert.rejects(client.query({ prompt: "invalid error" }), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "process_lost"); + assert.equal(error.outcomeCertainty, "unknown"); + return true; + }); + await client.close(); +}); + +test("ambiguous JSON-RPC response envelopes fail the protocol closed", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const host = runAmbiguousResponseFixtureHost(clientToHost, hostToClient); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + await assert.rejects(client.query({ prompt: "reject ambiguous response" }), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "process_lost"); + assert.equal(error.outcomeCertainty, "unknown"); + return true; + }); + await client.close(); +}); + +test("unknown Query event and Result status fail the protocol closed", async (context) => { + const cases = [ + { + name: "unknown event", + notification: { + jsonrpc: "2.0", + method: "query/event", + params: { + queryId: "query-invalid", + sessionId: "session-invalid", + turnId: "turn-invalid", + operationId: "operation-invalid", + sequence: 1, + event: { type: "permission_request", text: "must not be dropped" }, + }, + }, + appendValidResult: true, + }, + { + name: "unknown Result status", + notification: { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: "query-invalid", + sessionId: "session-invalid", + turnId: "turn-invalid", + operationId: "operation-invalid", + status: "partially_completed", + output: { text: "invalid" }, + }, + }, + appendValidResult: false, + }, + ] as const; + + for (const fixture of cases) { + await context.test(fixture.name, async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const host = runInvalidNotificationFixtureHost( + clientToHost, + hostToClient, + fixture.notification, + fixture.appendValidResult, + ); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + { cwd: "D:/workspace/project" }, + ); + + try { + const query = await client.query({ prompt: "reject protocol drift" }); + await assert.rejects(query.result(), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "process_lost"); + assert.equal(error.outcomeCertainty, "unknown"); + return true; + }); + } finally { + await client.close(); + } + }); + } +}); + +async function runFixtureHost( + requests: PassThrough, + responses: PassThrough, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { + id: number; + method: string; + params: Record; + }; + if (request.method === "initialize") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + protocolVersion: 1, + runtimeVersion: "0.2.17", + stability: "not_delivered", + capabilities: { + sessionCreate: true, + sessionCreateLifetime: "connection", + query: true, + queryCancel: true, + sessionClose: true, + eventStream: true, + structuredOutput: false, + usage: false, + customTools: false, + permissionCallbacks: false, + hooks: false, + mcpConfiguration: false, + prestartedTransport: false, + }, + }, + }); + continue; + } + if (request.method === "query/start") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + accepted: true, + createdSession: true, + sessionLifetime: "connection", + }, + }); + write(responses, { + jsonrpc: "2.0", + method: "query/event", + params: { + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + sequence: 1, + event: { type: "assistant_text_delta", text: "fixture result" }, + }, + }); + write(responses, { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + status: "completed", + output: { text: "fixture result" }, + }, + }); + continue; + } + if (request.method === "session/close") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { sessionId: "session-1", unloaded: true }, + }); + continue; + } + if (request.method === "shutdown") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { accepted: true }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + +async function runSessionFixtureHost( + requests: PassThrough, + responses: PassThrough, + methods: string[], +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { + id: number; + method: string; + params: Record; + }; + methods.push(request.method); + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "session/create") { + assert.deepEqual(request.params, { + sessionName: null, + agent: "agentic", + cwd: "D:/workspace/project", + model: null, + }); + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + sessionId: "session-explicit", + sessionName: "Explicit", + agent: "agentic", + lifetime: "connection", + }, + }); + continue; + } + if (request.method === "query/start") { + assert.deepEqual(request.params, { + prompt: "continue", + sessionId: "session-explicit", + }); + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-explicit", + sessionId: "session-explicit", + turnId: "turn-explicit", + operationId: "operation-explicit", + accepted: true, + createdSession: false, + sessionLifetime: "connection", + }, + }); + write(responses, { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: "query-explicit", + sessionId: "session-explicit", + turnId: "turn-explicit", + operationId: "operation-explicit", + status: "completed", + output: { text: "continued" }, + }, + }); + continue; + } + if (request.method === "session/close") { + assert.deepEqual(request.params, { + sessionId: "session-explicit", + }); + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { sessionId: "session-explicit", unloaded: true }, + }); + continue; + } + if (request.method === "shutdown") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { accepted: true }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + +async function runCancelFixtureHost( + requests: PassThrough, + responses: PassThrough, + onCancel: () => void, + emitEventAfterStart = false, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { + id: number; + method: string; + params: Record; + }; + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "query/start") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-cancel", + sessionId: "session-cancel", + turnId: "turn-cancel", + operationId: "operation-cancel", + accepted: true, + createdSession: true, + sessionLifetime: "connection", + }, + }); + if (emitEventAfterStart) { + write(responses, { + jsonrpc: "2.0", + method: "query/event", + params: { + queryId: "query-cancel", + sessionId: "session-cancel", + turnId: "turn-cancel", + operationId: "operation-cancel", + sequence: 1, + event: { type: "assistant_text_delta", text: "partial" }, + }, + }); + } + continue; + } + if (request.method === "query/cancel") { + onCancel(); + assert.deepEqual(request.params, { + queryId: "query-cancel", + sessionId: "session-cancel", + turnId: "turn-cancel", + operationId: "operation-cancel", + }); + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-cancel", + sessionId: "session-cancel", + turnId: "turn-cancel", + operationId: "operation-cancel", + requested: true, + }, + }); + write(responses, { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: "query-cancel", + sessionId: "session-cancel", + turnId: "turn-cancel", + operationId: "operation-cancel", + status: "cancelled", + output: { text: "" }, + error: { + message: "cancelled by caller", + data: { + code: "cancelled", + stage: "query", + retryable: false, + correlationId: "operation-cancel", + operationId: "operation-cancel", + outcomeCertainty: "committed", + }, + }, + }, + }); + continue; + } + if (request.method === "session/close") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { sessionId: "session-cancel", unloaded: true }, + }); + continue; + } + if (request.method === "shutdown") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { accepted: true }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + +async function runProcessLossFixtureHost( + requests: PassThrough, + responses: PassThrough, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { id: number; method: string }; + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "query/start") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-lost", + sessionId: "session-lost", + turnId: "turn-lost", + operationId: "operation-lost", + accepted: true, + createdSession: true, + sessionLifetime: "connection", + }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + +async function runClientCloseFixtureHost( + requests: PassThrough, + responses: PassThrough, + methods: string[], +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { + id: number; + method: string; + params: Record; + }; + methods.push(request.method); + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "query/start") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-client-close", + sessionId: "session-client-close", + turnId: "turn-client-close", + operationId: "operation-client-close", + accepted: true, + createdSession: true, + sessionLifetime: "connection", + }, + }); + continue; + } + if (request.method === "query/cancel") { + assert.deepEqual(request.params, { + queryId: "query-client-close", + sessionId: "session-client-close", + turnId: "turn-client-close", + operationId: "operation-client-close", + }); + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-client-close", + sessionId: "session-client-close", + turnId: "turn-client-close", + operationId: "operation-client-close", + requested: true, + }, + }); + write(responses, { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: "query-client-close", + sessionId: "session-client-close", + turnId: "turn-client-close", + operationId: "operation-client-close", + status: "cancelled", + output: { text: "" }, + error: { + message: "cancelled during client close", + data: { + code: "cancelled", + stage: "query", + retryable: false, + correlationId: "operation-client-close", + operationId: "operation-client-close", + outcomeCertainty: "committed", + }, + }, + }, + }); + continue; + } + if (request.method === "session/close") { + assert.deepEqual(request.params, { sessionId: "session-client-close" }); + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { sessionId: "session-client-close", unloaded: true }, + }); + continue; + } + if (request.method === "shutdown") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { accepted: true }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + +async function runOperationErrorFixtureHost( + requests: PassThrough, + responses: PassThrough, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { id: number; method: string }; + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "query/start") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + error: { + code: -32000, + message: "authentication is required", + data: { + code: "action_required", + stage: "query", + retryable: false, + correlationId: "request:query-start", + outcomeCertainty: "not_started", + recovery: "initialize", + }, + }, + }); + continue; + } + if (request.method === "shutdown") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { accepted: true }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + +async function runInvalidErrorFixtureHost( + requests: PassThrough, + responses: PassThrough, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { id: number; method: string }; + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "query/start") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + error: { + code: -32000, + message: "unknown error contract", + data: { + code: "invented_error", + stage: "query", + retryable: false, + correlationId: "request:invalid", + outcomeCertainty: "not_started", + }, + }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + +async function runAmbiguousResponseFixtureHost( + requests: PassThrough, + responses: PassThrough, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { id: number; method: string }; + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "query/start") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-ambiguous", + sessionId: "session-ambiguous", + turnId: "turn-ambiguous", + operationId: "operation-ambiguous", + accepted: true, + createdSession: true, + sessionLifetime: "connection", + }, + error: { + code: -32000, + message: "must not coexist with result", + data: { + code: "internal", + stage: "query", + retryable: false, + correlationId: "request:ambiguous", + outcomeCertainty: "not_started", + }, + }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + +async function runInvalidNotificationFixtureHost( + requests: PassThrough, + responses: PassThrough, + notification: unknown, + appendValidResult: boolean, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { id: number; method: string }; + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "query/start") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + queryId: "query-invalid", + sessionId: "session-invalid", + turnId: "turn-invalid", + operationId: "operation-invalid", + accepted: true, + createdSession: true, + sessionLifetime: "connection", + }, + }); + write(responses, notification); + if (appendValidResult) { + write(responses, { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: "query-invalid", + sessionId: "session-invalid", + turnId: "turn-invalid", + operationId: "operation-invalid", + status: "completed", + output: { text: "must not become authoritative" }, + }, + }); + } + continue; + } + if (request.method === "shutdown") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { accepted: true }, + }); + responses.end(); + return; + } + } + responses.end(); +} + +function initializeResponse(id: number): unknown { + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + runtimeVersion: "0.2.17", + stability: "not_delivered", + capabilities: { + sessionCreate: true, + sessionCreateLifetime: "connection", + query: true, + queryCancel: true, + sessionClose: true, + eventStream: true, + structuredOutput: false, + usage: false, + customTools: false, + permissionCallbacks: false, + hooks: false, + mcpConfiguration: false, + prestartedTransport: false, + }, + }, + }; +} + +function write(stream: PassThrough, value: unknown): void { + stream.write(`${JSON.stringify(value)}\n`); +} diff --git a/sdk/typescript/test/fixtures/host.mjs b/sdk/typescript/test/fixtures/host.mjs new file mode 100644 index 0000000000..0fea572501 --- /dev/null +++ b/sdk/typescript/test/fixtures/host.mjs @@ -0,0 +1,42 @@ +import { createInterface } from "node:readline"; + +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); +for await (const line of lines) { + const request = JSON.parse(line); + if (request.method === "initialize") { + write({ + jsonrpc: "2.0", + id: request.id, + result: { + protocolVersion: 1, + runtimeVersion: "fixture", + stability: "not_delivered", + capabilities: { + sessionCreate: true, + sessionCreateLifetime: "connection", + query: true, + queryCancel: true, + sessionClose: true, + eventStream: true, + structuredOutput: false, + usage: false, + customTools: false, + permissionCallbacks: false, + hooks: false, + mcpConfiguration: false, + prestartedTransport: false, + }, + }, + }); + continue; + } + if (request.method === "shutdown") { + write({ jsonrpc: "2.0", id: request.id, result: { accepted: true } }); + break; + } + throw new Error(`Unexpected method: ${request.method}`); +} + +function write(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} diff --git a/sdk/typescript/test/fixtures/unresponsive-host.mjs b/sdk/typescript/test/fixtures/unresponsive-host.mjs new file mode 100644 index 0000000000..ad87d63958 --- /dev/null +++ b/sdk/typescript/test/fixtures/unresponsive-host.mjs @@ -0,0 +1,15 @@ +import { spawn } from "node:child_process"; + +const grandchild = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { stdio: "ignore", windowsHide: true }, +); + +process.stdout.write(`${grandchild.pid}\n`); +if (process.argv[2] === "exit-parent") { + grandchild.unref(); + process.exit(0); +} +process.stdin.resume(); +setInterval(() => {}, 1000); diff --git a/sdk/typescript/test/lifecycle-timeouts.test.ts b/sdk/typescript/test/lifecycle-timeouts.test.ts new file mode 100644 index 0000000000..b63903a481 --- /dev/null +++ b/sdk/typescript/test/lifecycle-timeouts.test.ts @@ -0,0 +1,251 @@ +import assert from "node:assert/strict"; +import { createInterface } from "node:readline"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { SdkError } from "../src/errors.js"; +import { createAgentClient } from "../src/internal/client.js"; +import { JsonRpcConnection } from "../src/internal/json-rpc.js"; +import type { + QueryStartResult, + SessionCreateResult, +} from "../src/internal/wire/index.js"; +import { Query } from "../src/query.js"; +import { Session } from "../src/session.js"; + +test("client initialization aborts the Host connection after its startup deadline", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const starting = createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + hostToClient.end(); + }, + }, + { cwd: "D:/workspace/project", initializeTimeoutMs: 20 }, + ); + + await assert.rejects(withTestDeadline(starting), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "timeout"); + assert.equal(error.stage, "initialize"); + assert.equal(error.outcomeCertainty, "unknown"); + assert.equal(error.recovery, "restart_host"); + return true; + }); + assert.equal(transportClosed, true); +}); + +test("Query.close aborts an unresponsive Host after its cleanup deadline", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const connection = new JsonRpcConnection({ + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + hostToClient.end(); + }, + }); + const started: QueryStartResult = { + queryId: "query-timeout", + sessionId: "session-timeout", + turnId: "turn-timeout", + operationId: "operation-timeout", + accepted: true, + createdSession: true, + sessionLifetime: "connection", + }; + const createQuery = Query.create as unknown as ( + owner: JsonRpcConnection, + accepted: QueryStartResult, + closeTimeoutMs: number, + ) => Query; + const query = createQuery(connection, started, 20); + void query.result().catch(() => {}); + const closing = query.close(); + + try { + await assert.rejects(withTestDeadline(closing), isCleanupTimeout); + assert.equal(transportClosed, true); + } finally { + hostToClient.end(); + await closing.catch(() => {}); + } +}); + +test("connection shutdown closes transport when the Host never responds", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const connection = new JsonRpcConnection({ + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + hostToClient.end(); + }, + }); + const shutdown = connection.shutdown as unknown as (timeoutMs: number) => Promise; + const closing = shutdown.call(connection, 20); + + try { + await assert.rejects(withTestDeadline(closing), isCleanupTimeout); + assert.equal(transportClosed, true); + } finally { + hostToClient.end(); + await closing.catch(() => {}); + } +}); + +test("Session.close aborts an unresponsive Host after its cleanup deadline", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const connection = new JsonRpcConnection({ + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + hostToClient.end(); + }, + }); + const created: SessionCreateResult = { + sessionId: "session-timeout", + sessionName: "timeout", + agent: "agentic", + lifetime: "connection", + }; + const createSession = Session.create as unknown as ( + owner: JsonRpcConnection, + accepted: SessionCreateResult, + onQuery: (query: Query) => Query, + closeTimeoutMs: number, + ) => Session; + const session = createSession(connection, created, (query) => query, 20); + const closing = session.close(); + + try { + await assert.rejects(withTestDeadline(closing), isCleanupTimeout); + assert.equal(transportClosed, true); + } finally { + hostToClient.end(); + await closing.catch(() => {}); + } +}); + +test("implicit Session cleanup with unknown outcome makes the Query connection unusable", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const connection = new JsonRpcConnection({ + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + hostToClient.end(); + }, + }); + const started: QueryStartResult = { + queryId: "query-uncertain-session-cleanup", + sessionId: "session-uncertain-cleanup", + turnId: "turn-uncertain-cleanup", + operationId: "operation-uncertain-cleanup", + accepted: true, + createdSession: true, + sessionLifetime: "connection", + }; + const query = Query.create(connection, started); + write(hostToClient, { + jsonrpc: "2.0", + method: "query/result", + params: { + queryId: started.queryId, + sessionId: started.sessionId, + turnId: started.turnId, + operationId: started.operationId, + status: "completed", + output: { text: "done" }, + }, + }); + await query.result(); + const host = respondWithUncertainSessionCleanup(clientToHost, hostToClient); + + try { + await assert.rejects(query.close(), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "cleanup_required"); + assert.equal(error.outcomeCertainty, "unknown"); + assert.equal(error.recovery, "restart_host"); + return true; + }); + assert.equal(transportClosed, true); + } finally { + hostToClient.end(); + await host; + } +}); + +function isCleanupTimeout(error: unknown): boolean { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "cleanup_required"); + assert.equal(error.outcomeCertainty, "unknown"); + assert.equal(error.recovery, "restart_host"); + return true; +} + +async function withTestDeadline(promise: Promise): Promise { + let timer: ReturnType | undefined; + const expired = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("cleanup did not honor its deadline")), 250); + }); + try { + return await Promise.race([promise, expired]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +async function respondWithUncertainSessionCleanup( + requests: PassThrough, + responses: PassThrough, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { id: number; method: string }; + assert.equal(request.method, "session/close"); + write(responses, { + jsonrpc: "2.0", + id: request.id, + error: { + code: -32000, + message: "cleanup outcome is unknown", + data: { + code: "cleanup_required", + stage: "session", + retryable: false, + correlationId: "session:uncertain-cleanup", + outcomeCertainty: "unknown", + recovery: "restart_host", + }, + }, + }); + return; + } +} + +function write(stream: PassThrough, value: unknown): void { + stream.write(`${JSON.stringify(value)}\n`); +} diff --git a/sdk/typescript/test/managed-host.test.ts b/sdk/typescript/test/managed-host.test.ts new file mode 100644 index 0000000000..9667f012ba --- /dev/null +++ b/sdk/typescript/test/managed-host.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { AgentClient, SdkError } from "../src/index.js"; +import { createAgentClient } from "../src/internal/client.js"; +import { forceKillTree, startManagedHost } from "../src/internal/managed-host.js"; + +test("the managed transport owns one child Host process", async () => { + const fixture = fileURLToPath( + new URL("../../../../test/fixtures/host.mjs", import.meta.url), + ); + const transport = await startManagedHost({ + executable: process.execPath, + args: [fixture], + cwd: process.cwd(), + }); + const client = await createAgentClient(transport, { cwd: process.cwd() }); + + assert.ok(client instanceof AgentClient); + await client.close(); + assert.equal(transport.exitCode(), 0); +}); + +test("AgentClient.start reports a missing Host before an operation begins", async () => { + await assert.rejects( + AgentClient.start({ + cwd: process.cwd(), + hostPath: fileURLToPath( + new URL("../../../../test/fixtures/missing-host", import.meta.url), + ), + }), + (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "not_found"); + assert.equal(error.stage, "initialize"); + assert.equal(error.outcomeCertainty, "not_started"); + return true; + }, + ); +}); + +test("forced managed Host cleanup reclaims its descendant process tree", async () => { + const fixture = fileURLToPath( + new URL("../../../../test/fixtures/unresponsive-host.mjs", import.meta.url), + ); + const options = { + executable: process.execPath, + args: [fixture], + cwd: process.cwd(), + gracefulShutdownMs: 20, + forceKillTimeoutMs: 2_000, + } as Parameters[0]; + const transport = await startManagedHost(options); + const [chunk] = (await once(transport.readable, "data")) as [Buffer]; + const descendantPid = Number.parseInt(chunk.toString("utf8").trim(), 10); + assert.equal(Number.isSafeInteger(descendantPid), true); + + try { + await transport.close(); + assert.equal(transport.hasExited(), true); + assert.equal(await processExited(descendantPid), true); + } finally { + if (!(await processExited(descendantPid, 50))) { + try { + process.kill(descendantPid); + } catch { + // The descendant exited between the final probe and cleanup. + } + } + } +}); + +test( + "Unix cleanup reclaims the process group after the direct Host exits first", + { skip: process.platform === "win32" }, + async () => { + const fixture = fileURLToPath( + new URL("../../../../test/fixtures/unresponsive-host.mjs", import.meta.url), + ); + const transport = await startManagedHost({ + executable: process.execPath, + args: [fixture, "exit-parent"], + cwd: process.cwd(), + gracefulShutdownMs: 20, + forceKillTimeoutMs: 2_000, + }); + const [chunk] = (await once(transport.readable, "data")) as [Buffer]; + const descendantPid = Number.parseInt(chunk.toString("utf8").trim(), 10); + if (!transport.readable.readableEnded) { + await once(transport.readable, "end"); + } + assert.equal(transport.hasExited(), true); + + try { + await transport.close(); + assert.equal(await processExited(descendantPid), true); + } finally { + if (!(await processExited(descendantPid, 50))) { + try { + process.kill(descendantPid); + } catch { + // The descendant exited between the final probe and cleanup. + } + } + } + }, +); + +test( + "Windows process-tree cleanup rejects a non-zero taskkill result", + { skip: process.platform !== "win32" }, + async () => { + await assert.rejects(forceKillTree(0, 2_000), (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "cleanup_required"); + assert.equal(error.outcomeCertainty, "unknown"); + return true; + }); + }, +); + +async function processExited(pid: number, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs; + do { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") { + return true; + } + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } while (Date.now() < deadline); + return false; +} diff --git a/sdk/typescript/test/public-contract.test.ts b/sdk/typescript/test/public-contract.test.ts new file mode 100644 index 0000000000..531b636ddf --- /dev/null +++ b/sdk/typescript/test/public-contract.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("public declarations do not expose internal wire DTOs", async () => { + const names = ["client", "errors", "query", "session", "types"]; + const declarations = new Map( + await Promise.all( + names.map(async (name) => [ + name, + await readFile(new URL(`../src/${name}.d.ts`, import.meta.url), "utf8"), + ] as const), + ), + ); + + for (const declaration of declarations.values()) { + assert.doesNotMatch(declaration, /internal\/wire/); + } + + assert.match(declarations.get("client") ?? "", /private constructor\(\)/); + assert.match(declarations.get("query") ?? "", /private constructor\(\)/); + assert.match( + declarations.get("session") ?? "", + /class Sessions[\s\S]*?private constructor\(\)/, + ); + assert.match( + declarations.get("session") ?? "", + /class Session[\s\S]*?private constructor\(\)/, + ); +}); + +test("package files keep internal declarations and wire DTOs private", async () => { + const packageJson = JSON.parse( + await readFile( + new URL("../../../../package.json", import.meta.url), + "utf8", + ), + ) as { files?: string[] }; + + assert.deepEqual(packageJson.files, [ + "dist/sdk/typescript/src/*.d.ts", + "dist/sdk/typescript/src/*.js", + "dist/sdk/typescript/src/internal/*.js", + "dist/src/crates/adapters/transport/typescript/src/*.js", + "README.md", + ]); +}); diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000000..8527f7eaba --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "rootDir": "../..", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "stripInternal": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noEmitOnError": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../src/crates/adapters/transport/typescript/test/**/*.ts" + ] +} diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 9d7c98b672..7098a99fbc 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -42,8 +42,9 @@ ChatView -> TuiAgentClient -> TuiBackend -> App Server client Embedded TUI uses an in-memory App Server connection. During the migration, Shared TUI uses a CLI Host compatibility adapter that implements `TuiBackend` over the private versioned Runtime IPC; the TUI client and controllers must not -reference that IPC. Replacing the physical Shared transport with App Server -Pipe/UDS framing belongs to Phase 5. Side-effecting operations need stable +reference that IPC. Embedded direct-runtime migration belongs to Phase 5; +replacing the physical Shared transport with App Server Pipe/UDS framing belongs +to the separately reviewed Phase 6. Side-effecting operations need stable identities, controller/idle rules, bounded frames, and outcome-unknown handling before a connection can retry. diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 7210dd2839..e98d8add2d 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -30,8 +30,10 @@ path = "tests/terminal_process_contracts.rs" [dependencies] # Internal crates -bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = [ +bitfun-core = { path = "../../crates/assembly/core", features = [ "agent-runtime", + "document-read", + "subscription-auth", "remote-connect", "deep-research", "lsp", @@ -50,14 +52,14 @@ bitfun-core = { path = "../../crates/assembly/core", default-features = false, f ] } bitfun-events = { path = "../../crates/contracts/events" } bitfun-core-types = { path = "../../crates/contracts/core-types" } -bitfun-acp = { path = "../../crates/interfaces/acp" } -bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } +bitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client", "server"] } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } -bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } +bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "git-port", "permission", "plugin-runtime", "workspace-ports"] } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } -bitfun-services-core = { path = "../../crates/services/services-core", default-features = false, features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership"] } +bitfun-services-core = { path = "../../crates/services/services-core", features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership"] } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } -bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["external-sources"] } +bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["external-sources"] } bitfun-app-server = { path = "../../crates/interfaces/app-server" } bitfun-app-server-client = { path = "../../crates/interfaces/app-server-client" } bitfun-app-server-protocol = { path = "../../crates/interfaces/app-server-protocol" } @@ -76,7 +78,6 @@ toml = { workspace = true } # Session management uuid = { workspace = true } chrono = { workspace = true } -dashmap = { workspace = true } # Async trait async-trait = { workspace = true } @@ -95,7 +96,6 @@ similar = { workspace = true } # Syntax highlighting for code blocks and tool cards syntect = { workspace = true } -syntect-tui = { workspace = true } # Lazy initialization for syntax highlighter singleton once_cell = { workspace = true } @@ -119,7 +119,7 @@ fs2 = { workspace = true } base64 = { workspace = true } image = { workspace = true } minisign-verify = "0.2" -reqwest = { workspace = true, features = ["rustls"] } +reqwest = { workspace = true, features = ["http2", "rustls", "stream"] } sha2 = { workspace = true } tar = { workspace = true } tempfile = "3" diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 369677501a..906a667ffc 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -13,8 +13,8 @@ use tokio::sync::{broadcast, Mutex}; use bitfun_agent_runtime::sdk::{ AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentEventReceiver, - AgentInputAttachment, AgentLocalCommandTurnRecordRequest, - AgentMessageWorkspaceReferencesRequest, AgentRuntime, AgentSessionCompactionRequest, + AgentInputAttachment, AgentMessageWorkspaceReferencesRequest, AgentRuntime, + AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, @@ -485,17 +485,6 @@ impl ExecAgentRuntimeClient { } } - pub(crate) async fn record_completed_local_command_turn( - &self, - request: AgentLocalCommandTurnRecordRequest, - ) -> Result<()> { - self.embedded_runtime("recording local command turns")? - .record_completed_local_command_turn(request) - .await - .map(|_| ()) - .map_err(|error| anyhow::anyhow!(error.into_message())) - } - pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { *self .approval_policy @@ -1543,6 +1532,9 @@ impl ExecAgentRuntimeClient { turn_id: turn_id.clone(), content, display_content, + // The CLI steer prompt is text; attachments ride turn submissions. + attachments: Vec::new(), + metadata: serde_json::Map::new(), }; match &self.backend { diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index 7c85d763d0..527508dfd5 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -33,17 +33,17 @@ use bitfun_product_domains::tool_permissions::{ use bitfun_runtime_ports::{ put_agent_workspace_references, AgentContextReloadRequest, AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentInputAttachment, - AgentLocalCommandTurnRecordRequest, AgentMessageWorkspaceReferencesRequest, - AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, - AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, - AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, - AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, - AgentSessionRenameRequest, AgentSessionRevertRequest, AgentSessionRevertResult, - AgentSessionSummary, AgentSessionUsageRequest, AgentSessionWorkspaceBinding, - AgentSubmissionSource, AgentTurnCancellationRequest, AgentTurnCancellationResult, - AgentTurnSettlementRequest, AgentUserShellCommandRequest, AgentWorkspaceReference, - AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, - DialogSubmissionPolicy, SessionExecutionTarget, SessionTranscript, WorkspaceDiffSnapshot, + AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, + AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionLineageCancellationRequest, + AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, + AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, + AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRevertRequest, + AgentSessionRevertResult, AgentSessionSummary, AgentSessionUsageRequest, + AgentSessionWorkspaceBinding, AgentSubmissionSource, AgentTurnCancellationRequest, + AgentTurnCancellationResult, AgentTurnSettlementRequest, AgentUserShellCommandRequest, + AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, + AgentWorkspaceReferenceSearchResult, DialogSubmissionPolicy, SessionExecutionTarget, + SessionTranscript, WorkspaceDiffSnapshot, }; use tokio::sync::{broadcast, Mutex}; @@ -769,16 +769,6 @@ impl TuiAgentClient { Ok(()) } - pub(crate) async fn record_completed_local_command_turn( - &self, - request: AgentLocalCommandTurnRecordRequest, - ) -> Result<()> { - self.backend - .record_local_command_turn(RecordLocalCommandTurnRequest(request)) - .await?; - Ok(()) - } - pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { *self .approval_policy @@ -1368,7 +1358,7 @@ impl TuiAgentClient { .map_err(|error| anyhow::anyhow!(error.message))?; let result = self .backend - .submit_dialog_turn(SubmitDialogTurnRequest(AgentDialogTurnRequest { + .submit_dialog_turn(SubmitDialogTurnRequest::from(AgentDialogTurnRequest { session_id: session_id.clone(), message, original_message, @@ -1429,6 +1419,8 @@ impl TuiAgentClient { turn_id, content, display_content, + attachments: Vec::new(), + metadata: serde_json::Map::new(), })) .await? .steering_id) diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index 5e200aea92..3e5ff86bc1 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -387,7 +387,9 @@ fn answer(request: DispatchAnswerRequest) -> Result { } fn append(request: DispatchAppendRequest) -> Result { - if request.content.trim().is_empty() { + // An attachment-only message is a real message; only one with neither text + // nor attachments is empty. + if request.content.trim().is_empty() && request.attachments.is_empty() { bail!("dispatch appended message cannot be empty"); } let total_bytes = request @@ -397,6 +399,7 @@ fn append(request: DispatchAppendRequest) -> Result { if total_bytes > MAX_DISPATCH_TEXT_BYTES { bail!("dispatch appended message exceeds the 32 KiB request limit"); } + validate_attachments(&request.attachments)?; let message_id = request.message_id.clone(); let store = DispatchStore::open_default()?; let accepted = store.enqueue_append_message(request)?; diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index 18ad8719d5..9deaea7e34 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -165,6 +165,10 @@ pub(crate) struct DispatchAppendRequest { pub(crate) content: String, #[serde(default)] pub(crate) display_content: Option, + /// Attachments injected into the running turn with the message. An older + /// controller omits the field entirely, which decodes to an empty list. + #[serde(default)] + pub(crate) attachments: Vec, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 0f4eec6aca..a5f115ddec 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -220,13 +220,55 @@ pub(crate) fn process_alive(pid: u32) -> bool { return false; }; // SAFETY: signal 0 performs liveness/permission checking only. - if unsafe { libc::kill(pid, 0) } == 0 { - return true; + if unsafe { libc::kill(pid, 0) } != 0 + && !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) + { + return false; + } + + #[cfg(target_os = "linux")] + { + // A zombie still answers to kill(0), but it has already exited and + // must not be treated as an authenticated leader for escalation. + if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { + if stat + .rsplit_once(") ") + .and_then(|(_, fields)| fields.split_whitespace().next()) + == Some("Z") + { + return false; + } + } + } + + #[cfg(target_os = "macos")] + { + // macOS also reports zombies as present to kill(0). Query the process + // state before using a leader PID to authenticate SIGKILL escalation; + // a failed/empty query means the process disappeared during the check. + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "stat="]) + .output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + return String::from_utf8_lossy(&output.stdout) + .trim_start() + .chars() + .next() + .is_some_and(|state| state != 'Z'); + } + + #[cfg(not(target_os = "macos"))] + { + true } - matches!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::EPERM) - ) } #[cfg(not(unix))] diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index 8ad9dcd157..02b594f0b8 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -2522,6 +2522,7 @@ mod tests { message_id: "message-1".to_string(), content: "Continue with tests".to_string(), display_content: None, + attachments: Vec::new(), }; assert!(store diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index 2a6679bce0..c37cbf41a8 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -505,6 +505,8 @@ async fn process_mailboxes( turn_id: turn_id.to_string(), content: request.content.clone(), display_content: request.display_content.clone(), + attachments: runtime_attachments(&request.attachments), + metadata: serde_json::Map::new(), }) .await .map_err(|error| anyhow!(error.into_message())) diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 82aa7d6513..bba902a043 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -23,10 +23,9 @@ use tokio::sync::broadcast::error::TryRecvError; use bitfun_app_server_protocol::model::{AddModelRequest, UpdateModelRequest}; use bitfun_app_server_protocol::skill::SkillSummary; use bitfun_app_server_protocol::subagent::SubagentSummary; -use bitfun_core_types::SessionUsageReport; use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use bitfun_runtime_ports::{ - AgentLocalCommandTurnRecordRequest, AgentSessionComposerUpdate, AgentSessionLineageEntry, + AgentSessionComposerUpdate, AgentSessionLineageEntry, AgentSessionLineageInspection, AgentSessionLineageSnapshot, AgentSessionUsageRequest, AgentTurnCancellationResult, AgentWorkspaceReferenceSearchResult, SessionTranscript, WorkspaceDiffSnapshot, diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 4435319964..d9048ee44f 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -92,20 +92,6 @@ fn apply_agent_mode_feedback( } } -fn usage_report_metadata(report: &SessionUsageReport) -> Result { - let usage_report = serde_json::to_value(report) - .map_err(|error| anyhow!("Failed to serialize usage report: {error}"))?; - Ok(serde_json::json!({ - "localCommandKind": "usage_report", - "reportId": report.report_id, - "schemaVersion": report.schema_version, - "generatedAt": report.generated_at, - "modelVisible": false, - "usageReport": usage_report, - "usageReportStatus": "completed", - })) -} - fn apply_model_selection_feedback( chat_state: &mut ChatState, selected_display_name: &str, @@ -252,6 +238,17 @@ impl ChatMode { .or_else(|| Some(self.agent.workspace_path_string())); let agent = self.agent.clone(); + /* + * Rendered into the conversation view and nowhere else. A report about + * a session is not an event in it, and this used to write one as a + * `local_command` Turn as well — which the desktop then loaded from + * disk and gave a numbered slot in its Turn rail, because the ordinals + * come from the backend catalog and the catalog counts what is stored. + * + * `add_assistant_message` is already the UI-only path: `turn_id: None`, + * never persisted, never in model context. In a terminal the scrollback + * is the record, so nothing here needs to replace what is being removed. + */ let report_result: Result = tokio::task::block_in_place(|| { let session_id = session_id.clone(); @@ -262,39 +259,21 @@ impl ChatMode { .filter(|path| !path.trim().is_empty()) .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; - let report = agent + agent .generate_session_usage_report(AgentSessionUsageRequest { - session_id: session_id.clone(), + session_id, workspace_path: Some(workspace_path), remote_connection_id: None, remote_ssh_host: None, include_hidden_subagents: true, }) - .await?; - - let markdown = render_usage_report_markdown(&report); - let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); - let metadata = usage_report_metadata(&report)?; - agent - .record_completed_local_command_turn(AgentLocalCommandTurnRecordRequest { - session_id, - content: markdown, - turn_id: Some(format!("local-usage-{}", report.report_id)), - timestamp_ms: Some(generated_at), - metadata: metadata.as_object().cloned().ok_or_else(|| { - anyhow!("Usage report metadata must be an object") - })?, - }) - .await?; - - Ok(report) + .await }) }); match report_result { Ok(report) => { - let markdown = render_usage_report_markdown(&report); - chat_state.add_assistant_message(markdown); + chat_state.add_assistant_message(render_usage_report_markdown(&report)); chat_view.set_status(Some("Usage report added to conversation".to_string())); } Err(error) => { @@ -857,10 +836,7 @@ fn session_update_unavailable_message(setting_name: &str, is_processing: bool) - #[cfg(test)] mod usage_metadata_tests { - use super::{ - session_update_allowed, session_update_unavailable_message, usage_report_metadata, - SessionUsageReport, - }; + use super::{session_update_allowed, session_update_unavailable_message}; #[test] fn session_update_is_rechecked_when_an_idle_popup_outlives_turn_start() { @@ -878,21 +854,4 @@ mod usage_metadata_tests { "Agent mode cannot be changed during the current turn." ); } - - #[test] - fn usage_metadata_preserves_the_existing_tui_transcript_schema() { - let mut report = SessionUsageReport::partial_unavailable("session-1", 1_778_347_200_000); - report.report_id = "usage-session-1-1778347200000".to_string(); - - let metadata = usage_report_metadata(&report).expect("usage metadata"); - - assert_eq!(metadata["localCommandKind"], "usage_report"); - assert_eq!(metadata["reportId"], report.report_id); - assert_eq!(metadata["schemaVersion"], report.schema_version); - assert_eq!(metadata["generatedAt"], report.generated_at); - assert_eq!(metadata["modelVisible"], false); - assert_eq!(metadata["usageReportStatus"], "completed"); - assert_eq!(metadata["usageReport"]["sessionId"], "session-1"); - assert_eq!(metadata.as_object().map(serde_json::Map::len), Some(7)); - } } diff --git a/src/apps/cli/src/peer_host/args.rs b/src/apps/cli/src/peer_host/args.rs index 25704eace1..cf282ab428 100644 --- a/src/apps/cli/src/peer_host/args.rs +++ b/src/apps/cli/src/peer_host/args.rs @@ -34,17 +34,6 @@ pub(crate) fn optional_bool(obj: &Value, camel: &str) -> Option { field(obj, camel, &snake).and_then(|v| v.as_bool()) } -pub(crate) fn get_usize(obj: &Value, camel: &str) -> Result { - let snake = camel_to_snake(camel); - let value = - field(obj, camel, &snake).ok_or_else(|| format!("Missing or invalid '{camel}' field"))?; - value - .as_u64() - .map(|n| n as usize) - .or_else(|| value.as_i64().filter(|n| *n >= 0).map(|n| n as usize)) - .ok_or_else(|| format!("Missing or invalid '{camel}' field")) -} - fn camel_to_snake(camel: &str) -> String { let mut out = String::with_capacity(camel.len() + 4); for (i, ch) in camel.chars().enumerate() { diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index 07c9ff545a..0a335ad12b 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -6,12 +6,14 @@ use bitfun_core::external_sources::{ apply_external_source_control_action, choose_external_mcp_conflict, choose_external_subagent_conflict, external_source_snapshot, get_external_source_control_snapshot, set_external_mcp_server_decision, - set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_subagent_model_binding, + set_external_mcp_servers_enabled, set_external_prompt_command_conflict_choice, + set_external_source_enabled, set_external_subagent_activation, + set_external_subagent_model_binding, set_external_subagents_enabled, set_external_tool_conflict_choice, set_external_tool_target_decision, - update_external_integration_policy, ExternalIntegrationPolicyMutation, - ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, - ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + set_external_tool_targets_enabled, update_external_integration_policy, + ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSubagentModelBindingTarget, }; use serde_json::Value; @@ -60,6 +62,28 @@ fn required_u64(request: &Value, key: &str) -> ExternalSourceOperationResult ExternalSourceOperationResult> { + let decisions = request + .get("decisions") + .and_then(Value::as_array) + .ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Missing or invalid 'decisions'") + })?; + decisions + .iter() + .map(|decision| { + Ok(( + required_string(decision, first_key)?, + required_string(decision, second_key)?, + )) + }) + .collect() +} + fn model_binding_target_field( request: &Value, key: &str, @@ -221,6 +245,16 @@ async fn dispatch_inner( ) .await } + "set_external_tool_targets_enabled_command" => { + set_external_tool_targets_enabled( + workspace, + decision_pairs(request, "approvalKey", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedCatalogGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "set_external_tool_conflict_choice_command" => { set_external_tool_conflict_choice( workspace, @@ -241,6 +275,16 @@ async fn dispatch_inner( ) .await } + "set_external_subagents_enabled_command" => { + set_external_subagents_enabled( + workspace, + decision_pairs(request, "candidateId", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedSubagentGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "set_external_subagent_model_binding_command" => { set_external_subagent_model_binding( workspace, @@ -273,6 +317,16 @@ async fn dispatch_inner( ) .await } + "set_external_mcp_servers_enabled_command" => { + set_external_mcp_servers_enabled( + workspace, + decision_pairs(request, "candidateId", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedMcpGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "choose_external_mcp_conflict_command" => { choose_external_mcp_conflict( workspace, @@ -395,4 +449,22 @@ mod tests { ExternalSourceOperationErrorCode::InvalidRequest ); } + + #[test] + fn peer_bulk_decisions_preserve_the_reviewed_identity_pairs() { + let request = serde_json::json!({ + "decisions": [ + { "candidateId": "agent-a", "decisionKey": "decision-a" }, + { "candidateId": "agent-b", "decisionKey": "decision-b" } + ] + }); + + assert_eq!( + decision_pairs(&request, "candidateId", "decisionKey").unwrap(), + vec![ + ("agent-a".to_string(), "decision-a".to_string()), + ("agent-b".to_string(), "decision-b".to_string()), + ] + ); + } } diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 5f45ea6875..f75a74f8fc 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -46,11 +46,14 @@ pub(crate) async fn dispatch( | "set_external_source_enabled_command" | "set_external_source_conflict_choice_command" | "set_external_tool_target_decision_command" + | "set_external_tool_targets_enabled_command" | "set_external_tool_conflict_choice_command" | "set_external_subagent_activation_command" + | "set_external_subagents_enabled_command" | "set_external_subagent_model_binding_command" | "choose_external_subagent_conflict_command" | "set_external_mcp_server_decision_command" + | "set_external_mcp_servers_enabled_command" | "choose_external_mcp_conflict_command" | "update_external_integration_policy_command" => { external_sources::dispatch(command, args, state).await @@ -91,7 +94,7 @@ pub(crate) async fn dispatch( "save_session_turn" => session::save_session_turn(state, args).await, // Snapshot / rollback - "rollback_to_turn" => snapshot::rollback_to_turn(state, args).await, + "rollback_session_to_turn" => snapshot::rollback_session_to_turn(state, args).await, "get_session_files" => snapshot::get_session_files(state, args).await, // Dialog / tools diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index f8ac8f11ba..ec4e1fa345 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -796,7 +796,7 @@ mod tests { let snapshot_source = include_str!("snapshot.rs"); let rollback = snapshot_source - .split_once("pub(crate) async fn rollback_to_turn") + .split_once("pub(crate) async fn rollback_session_to_turn") .expect("Peer rollback") .1 .split_once("#[cfg(test)]") diff --git a/src/apps/cli/src/peer_host/commands/snapshot.rs b/src/apps/cli/src/peer_host/commands/snapshot.rs index f6a25c2b12..cc3084a809 100644 --- a/src/apps/cli/src/peer_host/commands/snapshot.rs +++ b/src/apps/cli/src/peer_host/commands/snapshot.rs @@ -1,20 +1,16 @@ //! Snapshot / rollback HostInvoke handlers for CLI Peer Host. -use std::collections::HashSet; use std::path::PathBuf; use serde_json::{json, Value}; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_runtime_ports::{ - LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, - LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, + AgentSessionRollbackToTurnRequest, LocalWorkspaceSnapshotPort, + LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, PortError, PortErrorKind, }; -use crate::peer_host::args::{ - get_string, get_usize, optional_bool, optional_string, request_value, -}; -use crate::peer_host::fanout::fanout_peer_device_event; +use crate::peer_host::args::{get_string, optional_string, request_value}; use crate::peer_host::state::PeerHostState; use super::session::{ensure_session_workspace_runtime_ownership, resolved_session_storage_scope}; @@ -97,63 +93,6 @@ pub(super) async fn local_snapshot_session_stats( }) } -async fn rollback_local_workspace_files( - port: &dyn LocalWorkspaceSnapshotPort, - workspace_path: PathBuf, - session_id: String, - turn_index: usize, -) -> Result, String> { - port.rollback_workspace_files_to_turn(LocalWorkspaceSnapshotTurnRequest { - workspace_path, - session_id, - turn_index, - }) - .await - .map_err(|error| { - format!( - "Failed to rollback turn: {}", - snapshot_compatibility_error(error) - ) - }) -} - -fn history_rollback_partial_failure(error: impl std::fmt::Display) -> String { - format!( - "Workspace files were rolled back, but session history rollback failed. Reload the session before retrying: {error}" - ) -} - -fn rollback_device_events( - session_id: &str, - turn_index: usize, - files_count: usize, - delete_turns: bool, - deleted_turns_count: usize, -) -> Vec<(String, Value)> { - let mut events = Vec::with_capacity(if delete_turns { 2 } else { 1 }); - if delete_turns { - events.push(( - "conversation_turns_deleted".to_string(), - json!({ - "session_id": session_id, - "remaining_turns": turn_index, - "deleted_count": deleted_turns_count, - }), - )); - } - events.push(( - "turn_rolled_back".to_string(), - json!({ - "session_id": session_id, - "turn_index": turn_index, - "files_count": files_count, - "deleted_turns": delete_turns, - "deleted_turns_count": deleted_turns_count, - }), - )); - events -} - pub(crate) async fn get_session_files( state: &PeerHostState, args: &Value, @@ -185,125 +124,24 @@ pub(crate) async fn get_session_files( .collect::>())) } -pub(crate) async fn rollback_to_turn(state: &PeerHostState, args: &Value) -> Result { +pub(crate) async fn rollback_session_to_turn( + state: &PeerHostState, + args: &Value, +) -> Result { let request = request_value(args); - let session_id = get_string(request, "sessionId")?; - let workspace_path = get_string(request, "workspacePath")?; - let turn_index = get_usize(request, "turnIndex")?; - let delete_turns = optional_bool(request, "deleteTurns").unwrap_or(false); - - bitfun_agent_runtime::session_control::validate_session_id(&session_id)?; - require_complete_rollback_workspace(request, &workspace_path).await?; - let workspace = PathBuf::from(&workspace_path); - let scope = ensure_session_workspace_runtime_ownership(state, request)?; - let session_storage_path = resolved_session_storage_scope(state, scope).await?; - state - .compatibility - .ensure_session_loaded_from_storage_path(&session_storage_path, &session_id, false) - .await - .map_err(|error| format!("Failed to load session before rollback: {error}"))?; - let maintenance = state - .compatibility - .begin_session_maintenance(&session_storage_path, &session_id, 2_000) - .await - .map_err(|error| format!("Failed to quiesce session before rollback: {error}"))?; - let mut descendant_cancellation = state.turns.session_turns_for_cancellation(&session_id); - descendant_cancellation - .turns - .retain(|turn| turn.session_id != session_id); - state - .cancel_peer_turns(descendant_cancellation, "Peer session rollback") - .await - .map_err(|error| format!("Failed to cancel Peer descendants before rollback: {error}"))?; - state.turns.drain_session_turns(&session_id); - - let mutation = state - .compatibility - .begin_persisted_session_mutation(&session_storage_path, &session_id) - .await - .map_err(|error| format!("Failed to lock session rollback: {error}"))?; - state - .compatibility - .commit_session_revert_before_snapshot_mutation(&mutation) + let rollback_request: AgentSessionRollbackToTurnRequest = + serde_json::from_value(request.clone()) + .map_err(|error| format!("Invalid targeted Session rollback request: {error}"))?; + + bitfun_agent_runtime::session_control::validate_session_id(&rollback_request.session_id)?; + require_complete_rollback_workspace(request, &rollback_request.workspace_path).await?; + ensure_session_workspace_runtime_ownership(state, request)?; + let outcome = state + .agent_runtime + .rollback_session_to_turn(rollback_request) .await - .map_err(|error| { - format!("Failed to commit the staged Session undo before rollback: {error}") - })?; - - let rolled_back_parent_turn_ids = if delete_turns { - let turns = state - .compatibility - .load_persisted_session_turns_for_mutation(&mutation, None) - .await - .map_err(|error| format!("Failed to load turns before rollback: {error}"))?; - state - .compatibility - .validate_persisted_session_context_rollback(&mutation, turn_index) - .await - .map_err(|error| format!("Failed to validate session rollback: {error}"))?; - turns - .into_iter() - .filter(|turn| turn.turn_index >= turn_index) - .map(|turn| turn.turn_id) - .collect::>() - } else { - HashSet::new() - }; - - let restored_files = rollback_local_workspace_files( - state.local_workspace_snapshot.as_ref(), - workspace, - session_id.clone(), - turn_index, - ) - .await?; - - let restored_files_str: Vec = restored_files - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - - let deleted_turns_count = rolled_back_parent_turn_ids.len(); - if delete_turns { - if let Err(error) = state - .compatibility - .rollback_persisted_session_context_to_turn_start(&mutation, turn_index) - .await - { - return Err(history_rollback_partial_failure(error)); - } - - if !rolled_back_parent_turn_ids.is_empty() { - if let Err(error) = state - .compatibility - .delete_hidden_subagent_sessions_for_parent_turns( - &session_storage_path, - &session_id, - &rolled_back_parent_turn_ids, - ) - .await - { - tracing::warn!( - "Failed to delete hidden subagent sessions during rollback: session_id={session_id}, turn_index={turn_index}, error={error}" - ); - } - } - } - - drop(mutation); - drop(maintenance); - - for (event_name, payload) in rollback_device_events( - &session_id, - turn_index, - restored_files_str.len(), - delete_turns, - deleted_turns_count, - ) { - fanout_peer_device_event(event_name, payload).await; - } - - Ok(json!(restored_files_str)) + .map_err(|error| error.into_message())?; + serde_json::to_value(outcome).map_err(|error| error.to_string()) } #[cfg(test)] @@ -320,9 +158,8 @@ mod tests { use serde_json::json; use super::{ - history_rollback_partial_failure, local_snapshot_session_files, - local_snapshot_session_stats, require_complete_rollback_workspace, - require_local_snapshot_workspace, rollback_device_events, rollback_local_workspace_files, + local_snapshot_session_files, local_snapshot_session_stats, + require_complete_rollback_workspace, require_local_snapshot_workspace, snapshot_compatibility_error, }; @@ -330,10 +167,8 @@ mod tests { struct RecordingSnapshotPort { file_calls: AtomicUsize, stats_calls: AtomicUsize, - rollback_calls: AtomicUsize, file_request: Mutex>, stats_request: Mutex>, - rollback_request: Mutex>, } #[async_trait::async_trait] @@ -368,10 +203,8 @@ mod tests { async fn rollback_workspace_files_to_turn( &self, - request: LocalWorkspaceSnapshotTurnRequest, + _request: LocalWorkspaceSnapshotTurnRequest, ) -> PortResult> { - self.rollback_calls.fetch_add(1, Ordering::SeqCst); - *self.rollback_request.lock().expect("rollback request lock") = Some(request); Ok(vec![PathBuf::from("restored.txt")]) } } @@ -404,38 +237,15 @@ mod tests { let source = include_str!("snapshot.rs"); let rollback_source = &source[source - .find("pub(crate) async fn rollback_to_turn") + .find("pub(crate) async fn rollback_session_to_turn") .expect("rollback handler must exist")..]; let remote_guard = rollback_source - .find("require_complete_rollback_workspace(request, &workspace_path).await?") + .find("require_complete_rollback_workspace(request, &rollback_request.workspace_path)") .expect("rollback must have an explicit remote guard"); - let maintenance = rollback_source - .find("begin_session_maintenance") - .expect("host-owned maintenance must remain in the rollback flow"); - let cancellation = rollback_source - .find("cancel_peer_turns") - .expect("descendant cancellation must remain in the rollback flow"); - let revert_commit = rollback_source - .find("commit_session_revert_before_snapshot_mutation") - .expect("staged Session undo must be committed before legacy rollback"); - let file_rollback = rollback_source - .find("rollback_local_workspace_files(") - .expect("workspace-file rollback must remain in the rollback flow"); - let history_rollback = rollback_source - .find("rollback_persisted_session_context_to_turn_start") - .expect("history rollback must remain in the rollback flow"); - let event_projection = rollback_source - .find("rollback_device_events(") - .expect("rollback events must remain host-projected"); - assert!( - remote_guard < maintenance - && maintenance < cancellation - && cancellation < revert_commit - && revert_commit < file_rollback - && file_rollback < history_rollback - && history_rollback < event_projection, - "rollback must preserve remote guard, maintenance, cancellation, files, history, and event order" - ); + let runtime_call = rollback_source + .find(".rollback_session_to_turn(") + .expect("rollback must delegate to the Agent Session runtime"); + assert!(remote_guard < runtime_call); } #[tokio::test] @@ -459,17 +269,10 @@ mod tests { ) .await .expect("stats projection should succeed"); - let restored = - rollback_local_workspace_files(&port, workspace.clone(), "session-1".to_string(), 4) - .await - .expect("rollback projection should succeed"); - assert_eq!(port.file_calls.load(Ordering::SeqCst), 1); assert_eq!(port.stats_calls.load(Ordering::SeqCst), 1); - assert_eq!(port.rollback_calls.load(Ordering::SeqCst), 1); assert_eq!(files, vec![PathBuf::from("changed.txt")]); assert_eq!(stats.total_changes, 3); - assert_eq!(restored, vec![PathBuf::from("restored.txt")]); assert_eq!( port.file_request .lock() @@ -488,36 +291,6 @@ mod tests { .max_turn_exclusive, Some(2) ); - assert_eq!( - port.rollback_request - .lock() - .expect("rollback request lock") - .as_ref() - .expect("rollback request") - .turn_index, - 4 - ); - } - - #[test] - fn rollback_projection_preserves_partial_failure_and_event_order() { - assert_eq!( - history_rollback_partial_failure("history backend failed"), - "Workspace files were rolled back, but session history rollback failed. Reload the session before retrying: history backend failed" - ); - - let events = rollback_device_events("session-1", 4, 2, true, 3); - assert_eq!(events.len(), 2); - assert_eq!(events[0].0, "conversation_turns_deleted"); - assert_eq!(events[0].1["remaining_turns"], 4); - assert_eq!(events[0].1["deleted_count"], 3); - assert_eq!(events[1].0, "turn_rolled_back"); - assert_eq!(events[1].1["files_count"], 2); - assert_eq!(events[1].1["deleted_turns"], true); - - let file_only = rollback_device_events("session-1", 4, 2, false, 0); - assert_eq!(file_only.len(), 1); - assert_eq!(file_only[0].0, "turn_rolled_back"); } #[test] diff --git a/src/apps/cli/src/peer_host/control.rs b/src/apps/cli/src/peer_host/control.rs index af3dcc4b4d..ea795600c5 100644 --- a/src/apps/cli/src/peer_host/control.rs +++ b/src/apps/cli/src/peer_host/control.rs @@ -133,6 +133,9 @@ pub(crate) fn peer_mode_ping_value() -> Value { "ok": true, "peer": true, "device_id": device_id, + "capabilities": { + "targeted_session_rollback": true, + }, }) } diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 4e6770864c..b4c702e67b 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -96,8 +96,18 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. @@ -158,10 +168,30 @@ mod tests { "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", ] { assert!(is_local_only_command(command), "{command}"); } } + + /// The controller-side FE deny list is an optimization, not the boundary. + /// An older or non-FE controller still reaches this host. + #[test] + fn speech_capture_stays_on_the_controller_device() { + for command in [ + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } } diff --git a/src/apps/cli/src/peer_host/dispatch.rs b/src/apps/cli/src/peer_host/dispatch.rs index b42e16e6fa..0bb1d26a43 100644 --- a/src/apps/cli/src/peer_host/dispatch.rs +++ b/src/apps/cli/src/peer_host/dispatch.rs @@ -161,6 +161,10 @@ mod tests { assert_eq!(value.get("ok"), Some(&json!(true))); assert_eq!(value.get("peer"), Some(&json!(true))); assert!(value.get("device_id").and_then(|v| v.as_str()).is_some()); + assert_eq!( + value.pointer("/capabilities/targeted_session_rollback"), + Some(&json!(true)) + ); } other => panic!("unexpected response: {other:?}"), } diff --git a/src/apps/cli/src/peer_host/state.rs b/src/apps/cli/src/peer_host/state.rs index 8e0cad1f11..a928d7c3ab 100644 --- a/src/apps/cli/src/peer_host/state.rs +++ b/src/apps/cli/src/peer_host/state.rs @@ -449,18 +449,6 @@ impl PeerTurnTracker { .unwrap_or_default() } - pub(crate) fn session_turns_for_cancellation(&self, session_id: &str) -> PeerTurnDrain { - self.inner - .lock() - .map(|inner| { - let keys = session_tree_keys(&inner, session_id); - let mut drain = peer_turn_drain_for_keys(&inner, &keys); - merge_completed_background_subagents(&inner, &mut drain, Some(session_id)); - drain - }) - .unwrap_or_default() - } - pub(crate) fn interrupt_event_stream(&self, closed: bool) -> PeerTurnDrain { let Ok(mut inner) = self.inner.lock() else { return PeerTurnDrain::default(); diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index 2224dc8313..56e118a661 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -899,26 +899,33 @@ fn release_pubkey() -> Option<&'static str> { } /// Verify a Tauri-format `.sig` (base64 of a minisign signature file) over the -/// archive, using the base64-wrapped public key. +/// archive. The public key accepts both current raw Tauri values and the legacy +/// base64 wrapper. /// /// A checksum only proves the transfer was not corrupted: whoever serves the /// archive can serve a matching `.sha256`. A signature proves the bytes came /// from whoever holds the release key, which is what actually protects the /// third-party GitHub proxy and mirror paths. -fn verify_signature(archive: &[u8], signature_b64: &str, pubkey_b64: &str) -> Result<()> { +fn verify_signature(archive: &[u8], signature_b64: &str, pubkey: &str) -> Result<()> { use base64::Engine as _; - let decode = |value: &str, what: &str| -> Result { + + let public_key_text = if pubkey.trim().starts_with("untrusted comment:") { + pubkey.trim().to_owned() + } else { let bytes = base64::engine::general_purpose::STANDARD - .decode(value.trim().as_bytes()) - .with_context(|| format!("decode {what}"))?; - String::from_utf8(bytes).with_context(|| format!("decode {what} as UTF-8")) + .decode(pubkey.trim().as_bytes()) + .context("decode release public key")?; + String::from_utf8(bytes).context("decode release public key as UTF-8")? }; - - let public_key = minisign_verify::PublicKey::decode(&decode(pubkey_b64, "release public key")?) + let public_key = minisign_verify::PublicKey::decode(&public_key_text) .map_err(|error| anyhow!("invalid release public key: {error}"))?; - let signature = - minisign_verify::Signature::decode(&decode(signature_b64, "release signature")?) - .map_err(|error| anyhow!("invalid release signature: {error}"))?; + let signature_bytes = base64::engine::general_purpose::STANDARD + .decode(signature_b64.trim().as_bytes()) + .context("decode release signature")?; + let signature_text = + String::from_utf8(signature_bytes).context("decode release signature as UTF-8")?; + let signature = minisign_verify::Signature::decode(&signature_text) + .map_err(|error| anyhow!("invalid release signature: {error}"))?; public_key .verify(archive, &signature, false) .map_err(|error| anyhow!("release signature does not match the archive: {error}")) @@ -1128,7 +1135,7 @@ fn is_newer_version(candidate: &str, current: &str) -> bool { fn automatic_update_is_eligible() -> bool { if std::env::var_os("BITFUN_CLI_DISABLE_AUTO_UPDATE").is_some() - || env!("CARGO_PKG_VERSION").contains("-nightly.") + || !release_version_allows_automatic_update(env!("CARGO_PKG_VERSION")) { return false; } @@ -1137,6 +1144,10 @@ fn automatic_update_is_eligible() -> bool { .is_some_and(|path| current_platform_key().is_some() && !is_development_binary(&path)) } +fn release_version_allows_automatic_update(version: &str) -> bool { + !version.contains("-nightly.") && !version.contains("-beta.") +} + /// Share the CLI's own config directory so a relocated profile (E2E storage /// guard, non-default home) does not silently re-check on every launch. fn automatic_stamp_path() -> Option { @@ -1194,6 +1205,7 @@ fn restart_managed_daemon() { #[cfg(test)] mod tests { use super::*; + use base64::Engine as _; use sha2::{Digest, Sha256}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1446,6 +1458,15 @@ mod tests { assert!(!is_newer_version("0.2.12", "0.2.13")); } + #[test] + fn prerelease_cli_builds_do_not_use_the_stable_auto_update_feed() { + assert!(release_version_allows_automatic_update("0.2.14")); + assert!(!release_version_allows_automatic_update("0.2.14-beta.1")); + assert!(!release_version_allows_automatic_update( + "0.2.14-nightly.20260811" + )); + } + /// Fixture produced with the real `minisign` CLI, then wrapped the way /// Tauri wraps keys and signatures (base64 of the whole file), so this pins /// the exact on-disk format CI must emit. @@ -1457,6 +1478,14 @@ mod tests { fn release_signature_accepts_the_tauri_wire_format() { verify_signature(FIXTURE_DATA, FIXTURE_SIGNATURE, FIXTURE_PUBKEY) .expect("minisign signature in Tauri's base64 wrapper must verify"); + let raw_pubkey = String::from_utf8( + base64::engine::general_purpose::STANDARD + .decode(FIXTURE_PUBKEY) + .expect("decode fixture public key"), + ) + .expect("fixture public key is UTF-8"); + verify_signature(FIXTURE_DATA, FIXTURE_SIGNATURE, &raw_pubkey) + .expect("raw minisign public key must verify"); } #[test] diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 3c2cad6a38..a5af4e4b6c 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -593,12 +593,6 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .remove(&request.tool_id); Ok(RuntimeIpcOperationResult::Unit) } - RuntimeIpcOperation::RecordLocalCommandTurn { request } => self - .runtime - .record_completed_local_command_turn(request) - .await - .map(|record| RuntimeIpcOperationResult::LocalCommandTurnRecorded { record }) - .map_err(runtime_ipc_error), } } diff --git a/src/apps/cli/src/shared_tui_backend.rs b/src/apps/cli/src/shared_tui_backend.rs index fc7061cf86..f4c515e4d8 100644 --- a/src/apps/cli/src/shared_tui_backend.rs +++ b/src/apps/cli/src/shared_tui_backend.rs @@ -42,7 +42,7 @@ use bitfun_app_server_protocol::worktree::*; use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; use bitfun_runtime_ports::{ AgentSessionCompactionResult, AgentSessionForkResult, AgentSessionWorkspaceBinding, - AgentUserShellCommandResult, + AgentSubmissionSource, AgentUserShellCommandResult, DialogSubmissionPolicy, }; use tokio::sync::broadcast; @@ -421,7 +421,11 @@ impl TuiBackend for SharedTuiBackend { request: SubmitDialogTurnRequest, ) -> Result { match self - .request(RuntimeIpcOperation::SubmitTurn { request: request.0 }) + .request(RuntimeIpcOperation::SubmitTurn { + request: request.0.to_request(DialogSubmissionPolicy::for_source( + AgentSubmissionSource::Cli, + )), + }) .await? { RuntimeIpcOperationResult::TurnAccepted { @@ -502,21 +506,6 @@ impl TuiBackend for SharedTuiBackend { Ok(SubmitUserAnswersResponse {}) } - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::RecordLocalCommandTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::LocalCommandTurnRecorded { record } => { - Ok(RecordLocalCommandTurnResponse(record)) - } - other => Err(unexpected("record_local_command_turn", other)), - } - } - async fn respond_permission( &self, request: RespondPermissionRequest, @@ -1209,7 +1198,6 @@ fn tui_capabilities(management: &AppManagementCapabilities) -> Vec Result; - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result; async fn respond_permission( &self, request: RespondPermissionRequest, @@ -503,13 +499,6 @@ impl TuiBackend for AppServerTuiBackend { map_client(self.client.submit_user_answers(request).await) } - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result { - map_client(self.client.record_local_command_turn(request).await) - } - async fn respond_permission( &self, request: RespondPermissionRequest, diff --git a/src/apps/cli/src/ui/syntax_highlight.rs b/src/apps/cli/src/ui/syntax_highlight.rs index 1652fea129..1d2e7de5f6 100644 --- a/src/apps/cli/src/ui/syntax_highlight.rs +++ b/src/apps/cli/src/ui/syntax_highlight.rs @@ -1,7 +1,7 @@ /// Syntax highlighting module for TUI /// -/// Uses `syntect` for syntax analysis and `syntect-tui` to convert -/// highlighted output into ratatui `Span`s. +/// Uses `syntect` for syntax analysis and converts highlighted output directly +/// into ratatui `Span`s. use once_cell::sync::Lazy; use ratatui::{ style::Style, diff --git a/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs b/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs index 48862de237..6c6bba56da 100644 --- a/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs +++ b/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs @@ -367,6 +367,48 @@ fn stream_json_patch_success_emits_one_success_terminal() { ); } +#[test] +fn stream_json_malformed_sse_retries_then_completes() { + let server = MockOpenAiServer::malformed_sse_then_immediate(); + let environment = CliTestEnvironment::new(); + environment.configure_mock_model(server.base_url()); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise malformed provider stream retry", + "--output-format", + "stream-json", + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); + server.assert_chat_completion_requests(2); + + let stdout = stdout(&output); + assert!(output.status.success(), "{}\n{stdout}", stderr(&output)); + let events = jsonl_events(&stdout); + assert!( + events.iter().any(|value| { + value["event"]["type"] == "TextChunk" + && value["event"]["text"] + .as_str() + .is_some_and(|text| text.contains(STREAM_COMPLETED_MARKER)) + }), + "retried model stream did not complete: {stdout}" + ); + assert_eq!( + events + .iter() + .filter(|value| is_terminal_event(value)) + .count(), + 1, + "retried stream must emit exactly one terminal envelope: {stdout}" + ); + assert_eq!( + events.last().expect("retried stream terminal event")["event"]["type"], + "DialogTurnCompleted", + "retried stream terminal must be last: {stdout}" + ); +} + #[test] fn stream_json_provider_http_403_emits_one_error_terminal() { let server = MockOpenAiServer::http_403("provider authorization denied"); @@ -380,7 +422,7 @@ fn stream_json_provider_http_403_emits_one_error_terminal() { "stream-json", ]); let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); - server.assert_chat_completion_requests(1); + server.assert_chat_completion_requests(10); let stdout = stdout(&output); assert!(!output.status.success(), "{stdout}"); @@ -443,7 +485,7 @@ fn stream_json_provider_and_patch_failures_publish_one_final_classification() { &output_target, ]); let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); - server.assert_chat_completion_requests(1); + server.assert_chat_completion_requests(10); let stdout = stdout(&output); let stderr = stderr(&output); @@ -481,7 +523,7 @@ fn stream_json_provider_and_patch_failures_publish_one_final_classification() { } #[test] -fn stream_json_disconnect_then_permanent_retry_failure_emits_one_error_terminal() { +fn stream_json_disconnect_then_exhausted_retry_failure_emits_one_error_terminal() { let server = MockOpenAiServer::disconnect_then_http_403(); let environment = CliTestEnvironment::new(); environment.configure_mock_model(server.base_url()); @@ -493,7 +535,7 @@ fn stream_json_disconnect_then_permanent_retry_failure_emits_one_error_terminal( "stream-json", ]); let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); - server.assert_chat_completion_requests(2); + server.assert_chat_completion_requests(10); let stdout = stdout(&output); assert!(!output.status.success(), "{stdout}"); diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index 2f453a9c95..da2cb83a06 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -249,10 +249,19 @@ fn peer_session_control_and_usage_persistence_use_runtime_sdk() { "Peer Host session control must route {sdk_operation} through the Runtime SDK" ); } + // Inverted, along with the behaviour it described. `/usage` renders into + // the conversation view and writes nothing: a report about a session is not + // an event in it, and the Turn this used to persist was loaded back by the + // desktop and given a numbered slot in its Turn rail. `add_assistant_message` + // is the UI-only path — `turn_id: None`, never persisted — and in a terminal + // the scrollback is the record. + // + // Source text only. This says the call is absent, not that nothing persists; + // a behavioural guarantee would have to come from the runtime port's own + // tests. assert!( - CHAT_SELECTION.contains("record_completed_local_command_turn") - && !CHAT_SELECTION.contains("append_completed_local_command_turn"), - "TUI usage persistence must use the fixed-semantics Runtime SDK port" + !CHAT_SELECTION.contains("record_completed_local_command_turn"), + "/usage must not write a local_command Turn: it renders into the conversation view and persists nothing" ); for removed_compatibility_method in [ @@ -350,14 +359,13 @@ fn chat_context_reload_uses_the_same_tui_backend_as_session_operations() { } #[test] -fn tui_client_covers_interactive_permission_and_local_turn_operations() { +fn tui_client_covers_interactive_permission_operations() { const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); for sdk_operation in [ "subscribe_permission_requests", "pending_permission_requests", "respond_permission", - "record_completed_local_command_turn", ] { assert!( TUI_CLIENT.contains(sdk_operation), diff --git a/src/apps/cli/tests/support/mod.rs b/src/apps/cli/tests/support/mod.rs index b8c634b31c..03fcd71dc5 100644 --- a/src/apps/cli/tests/support/mod.rs +++ b/src/apps/cli/tests/support/mod.rs @@ -303,6 +303,7 @@ enum MockModelResponse { Gated, Http403 { reason: String }, DisconnectThenHttp403, + MalformedSseThenImmediate, } impl MockOpenAiServer { @@ -324,6 +325,10 @@ impl MockOpenAiServer { Self::spawn(MockModelResponse::DisconnectThenHttp403) } + pub(crate) fn malformed_sse_then_immediate() -> Self { + Self::spawn(MockModelResponse::MalformedSseThenImmediate) + } + pub(crate) fn base_url(&self) -> &str { &self.base_url } @@ -405,11 +410,14 @@ impl MockOpenAiServer { &disconnect_tx, ); attempt += 1; - if matches!( - response, - MockModelResponse::Http403 { .. } - | MockModelResponse::DisconnectThenHttp403 - ) { + let accepts_more_requests = + matches!( + response, + MockModelResponse::Http403 { .. } + | MockModelResponse::DisconnectThenHttp403 + ) || (matches!(response, MockModelResponse::MalformedSseThenImmediate) + && attempt < 2); + if accepts_more_requests { continue; } break; @@ -473,6 +481,13 @@ fn serve_model_response( ) .expect("write mock response headers"); + if matches!(response, MockModelResponse::MalformedSseThenImmediate) && attempt == 0 { + write_chunk(stream, b"data: not-json\n\n").expect("write malformed SSE frame"); + let _ = stream.write_all(b"0\r\n\r\n"); + let _ = stream.flush(); + return; + } + write_sse_chunk( stream, &json!({ @@ -579,7 +594,7 @@ fn write_http_403(stream: &mut TcpStream, reason: &str) -> std::io::Result<()> { .to_string(); write!( stream, - "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nRetry-After: 1\r\nConnection: close\r\n\r\n{body}", body.len() )?; stream.flush() diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 3cc388e6dd..2e09b88435 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -40,6 +40,7 @@ crate;`src/crates/assembly/core` 只保留产品装配与兼容桥接。 ```bash pnpm run desktop:dev pnpm run desktop:preview:debug +pnpm run prepare:dsh-profile # 可选:本地 DeepSeek Harness 会话 ``` ## 快速构建 diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index c8411a10d9..465a532e6c 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -49,6 +49,7 @@ the Verification section below. ```bash pnpm run desktop:dev pnpm run desktop:preview:debug +pnpm run prepare:dsh-profile # optional: local DeepSeek Harness sessions ``` ## Fast builds diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 5f96147e62..7c6ed07924 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -19,18 +19,18 @@ serde_json = { workspace = true } [dependencies] # Internal crates -bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +bitfun-core = { path = "../../crates/assembly/core", features = ["product-full"] } bitfun-relay-service = { path = "../../crates/services/relay-service" } -bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } -bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } -bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["appearance-market"] } -bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "miniapp-market", "speech", "feedback", "privacy"] } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } +bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "permission", "workspace-ports"] } +bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market"] } +bitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "speech", "feedback", "privacy"] } bitfun-core-types = { path = "../../crates/contracts/core-types" } -bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } +bitfun-agent-tools = { path = "../../crates/execution/tool-contracts", features = ["element-token"] } bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } bitfun-events = { path = "../../crates/contracts/events" } bitfun-webdriver = { path = "../../crates/adapters/webdriver" } -bitfun-acp = { path = "../../crates/interfaces/acp" } +bitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client"] } # Tauri tauri = { workspace = true } @@ -63,7 +63,7 @@ dirs = { workspace = true } similar = { workspace = true } ignore = { workspace = true } urlencoding = { workspace = true } -reqwest = { workspace = true, features = ["rustls"] } +reqwest = { workspace = true, features = ["http2", "json", "query", "rustls", "stream"] } semver = { workspace = true } zip = { workspace = true } tar = { workspace = true } @@ -93,9 +93,9 @@ dispatch = { workspace = true } foreign-types = { workspace = true } libc = { workspace = true } objc2 = { workspace = true, features = ["exception"] } -objc2-foundation = { workspace = true } +objc2-foundation = { workspace = true, features = ["std", "NSArray", "NSData", "NSDictionary", "NSError", "NSString"] } objc2-app-kit = { workspace = true } -objc2-vision = { workspace = true } +objc2-vision = { workspace = true, features = ["std", "VNRecognizeTextRequest", "VNRequest", "VNObservation", "VNRequestHandler", "VNUtils", "VNTypes", "objc2-core-foundation"] } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } diff --git a/src/apps/desktop/build.rs b/src/apps/desktop/build.rs index d84e5ec5db..8232623697 100644 --- a/src/apps/desktop/build.rs +++ b/src/apps/desktop/build.rs @@ -1,4 +1,7 @@ fn main() { + println!("cargo:rerun-if-env-changed=BITFUN_RELEASE_CHANNEL"); + println!("cargo:rerun-if-env-changed=BITFUN_UPDATER_PRIMARY_ENDPOINT"); + println!("cargo:rerun-if-env-changed=BITFUN_UPDATER_FALLBACK_ENDPOINT"); // The Windows primary thread keeps the Tauri event loop and native window // creation stack. Reserve the same headroom as the Tokio workers so a // large debug invoke dispatcher cannot exhaust the default 1 MiB stack. diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index 21f84c5c86..d18bf9024b 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -1,89 +1,103 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "BitFun default capabilities", - "windows": ["main", "agent-companion-pet", "spotlight"], - "permissions": [ - "log:default", - "core:default", - "core:path:default", - "core:event:default", - "core:event:allow-listen", - "core:event:allow-emit", - "core:window:default", - "core:webview:default", - "core:webview:allow-create-webview", - "core:webview:allow-set-webview-position", - "core:webview:allow-set-webview-size", - "core:webview:allow-set-webview-focus", - "core:webview:allow-reparent", - "core:webview:allow-webview-show", - "core:webview:allow-webview-hide", - "core:webview:allow-webview-close", - "core:window:allow-create", - "core:window:allow-set-focus", - "core:window:allow-set-always-on-top", - "core:window:allow-set-position", - "core:window:allow-set-size", - "core:window:allow-set-decorations", - "core:window:allow-set-title-bar-style", - "core:window:allow-set-skip-taskbar", - "core:window:allow-set-resizable", - "core:window:allow-current-monitor", - "core:window:allow-outer-position", - "core:window:allow-outer-size", - "core:window:allow-is-maximized", - "core:window:allow-center", - "core:window:allow-close", - "core:window:allow-hide", - "core:window:allow-maximize", - "core:window:allow-minimize", - "core:window:allow-show", - "core:window:allow-start-dragging", - "core:window:allow-unmaximize", - "core:window:allow-unminimize", - "core:window:allow-set-min-size", - "opener:default", - "opener:allow-open-url", - "opener:allow-open-path", - "opener:allow-reveal-item-in-dir", - "fs:default", - "fs:allow-read-file", - "fs:allow-write-file", - "fs:allow-read-dir", - "fs:allow-copy-file", - "fs:allow-create", - "fs:allow-mkdir", - "fs:allow-remove", - "fs:allow-rename", - "fs:allow-exists", - "fs:allow-read-text-file", - "fs:allow-write-text-file", - "fs:allow-stat", - "fs:allow-lstat", - "fs:allow-fstat", - "fs:allow-truncate", - "fs:allow-ftruncate", - "fs:allow-open", - "fs:allow-read", - "fs:allow-write", - "fs:allow-seek", - "fs:allow-unwatch", - "fs:allow-watch", - "fs:read-all", - "fs:write-all", - "fs:scope-app-recursive", - { - "identifier": "fs:allow-home-read-recursive", - "allow": [ - { "path": "$HOME/**" } - ] - }, - { - "identifier": "fs:allow-home-write-recursive", - "allow": [ - { "path": "$HOME/**" } - ] - } - ] + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "BitFun default capabilities", + "windows": ["main", "agent-companion-pet", "spotlight"], + "permissions": [ + "log:default", + "autostart:default", + "core:default", + "core:path:default", + "core:event:default", + "core:event:allow-listen", + "core:event:allow-emit", + "core:window:default", + "core:webview:default", + "core:webview:allow-create-webview", + "core:webview:allow-set-webview-position", + "core:webview:allow-set-webview-size", + "core:webview:allow-set-webview-focus", + "core:webview:allow-reparent", + "core:webview:allow-webview-show", + "core:webview:allow-webview-hide", + "core:webview:allow-webview-close", + "core:window:allow-create", + "core:window:allow-set-focus", + "core:window:allow-set-always-on-top", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-set-decorations", + "core:window:allow-set-title-bar-style", + "core:window:allow-set-skip-taskbar", + "core:window:allow-set-resizable", + "core:window:allow-current-monitor", + "core:window:allow-outer-position", + "core:window:allow-outer-size", + "core:window:allow-is-fullscreen", + "core:window:allow-is-maximized", + "core:window:allow-center", + "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-maximize", + "core:window:allow-minimize", + "core:window:allow-show", + "core:window:allow-start-dragging", + "core:window:allow-unmaximize", + "core:window:allow-unminimize", + "core:window:allow-set-min-size", + "dialog:default", + "dialog:allow-open", + "dialog:allow-save", + "dialog:allow-ask", + "dialog:allow-confirm", + "dialog:allow-message", + "opener:default", + "opener:allow-open-url", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "$APPDATA/**" }, + { "path": "$HOME/**" } + ] + }, + "opener:allow-reveal-item-in-dir", + "fs:default", + "fs:allow-read-file", + "fs:allow-write-file", + "fs:allow-read-dir", + "fs:allow-copy-file", + "fs:allow-create", + "fs:allow-mkdir", + "fs:allow-remove", + "fs:allow-rename", + "fs:allow-exists", + "fs:allow-read-text-file", + "fs:allow-write-text-file", + "fs:allow-stat", + "fs:allow-lstat", + "fs:allow-fstat", + "fs:allow-truncate", + "fs:allow-ftruncate", + "fs:allow-open", + "fs:allow-read", + "fs:allow-write", + "fs:allow-seek", + "fs:allow-unwatch", + "fs:allow-watch", + "fs:read-all", + "fs:write-all", + "fs:scope-app-recursive", + { + "identifier": "fs:allow-home-read-recursive", + "allow": [ + { "path": "$HOME/**" } + ] + }, + { + "identifier": "fs:allow-home-write-recursive", + "allow": [ + { "path": "$HOME/**" } + ] + } + ] } \ No newline at end of file diff --git a/src/apps/desktop/dmg/background.png b/src/apps/desktop/dmg/background.png new file mode 100644 index 0000000000..9fe35cd211 Binary files /dev/null and b/src/apps/desktop/dmg/background.png differ diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 2b4c6c468e..f77cf4c45c 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -16,12 +16,13 @@ use crate::runtime::{ use crate::startup_trace::DesktopStartupTrace; use bitfun_agent_runtime::deep_review::sanitize_focused_review_public_metadata; use bitfun_agent_runtime::sdk::{ - AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, - AgentInputAttachment, AgentSessionCreateResult, AgentSessionModeUpdateRequest, - AgentSessionModelSelection, AgentSessionModelSelectionUpdateRequest, - AgentSessionModelUpdateRequest, AgentSubmissionSource, AgentTurnCancellationRequest, - DialogSteerOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey, - PermissionReply, PermissionRequest, + AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRecoveryOutcome, + AgentDialogTurnRecoveryRequest, AgentDialogTurnRequest, AgentInputAttachment, + AgentSessionCreateResult, AgentSessionModeUpdateRequest, AgentSessionModelSelection, + AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, AgentSubmissionSource, + AgentTurnCancellationRequest, AgentTurnInterruptionRequest, DialogSteerOutcome, + PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PermissionReply, PermissionRequest, + RuntimeError, }; use bitfun_core::agentic::agents::AgentSource; use bitfun_core::agentic::coordination::{ @@ -65,7 +66,7 @@ use bitfun_core_types::{ WorktreeError, WorktreeErrorCode, }; use bitfun_product_domains::tool_permissions::PermissionRule; -use bitfun_runtime_ports::{PermissionMode, SessionTurnWindowRequest}; +use bitfun_runtime_ports::{PermissionMode, PortErrorKind, SessionTurnWindowRequest}; const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; @@ -252,6 +253,27 @@ pub struct UpdateSessionPermissionModeRequest { #[serde(default)] pub mode: Option, #[serde(default)] + pub turn_id: Option, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + #[serde(default)] + pub include_internal: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateActiveTurnPermissionModeRequest { + pub session_id: String, + pub turn_id: String, + /// `None` clears the one-off override and returns the active turn to its + /// session mode at the next model-round boundary. + #[serde(default)] + pub mode: Option, + #[serde(default)] pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -266,6 +288,9 @@ pub struct UpdateSessionPermissionModeRequest { pub struct SessionPermissionModeResponse { /// The session's own selection, or `null` when it follows the default. pub mode: Option, + /// Ephemeral override for the requested active turn, when one exists. + pub turn_mode: Option, + pub active_turn_id: Option, } fn deserialize_present_nullable<'de, D, T>(deserializer: D) -> Result>, D::Error> @@ -740,6 +765,20 @@ pub struct CancelDialogTurnRequest { pub dialog_turn_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecoverInterruptedDialogTurnRequest { + pub session_id: String, + pub dialog_turn_id: String, + pub execution_generation: u32, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SteerDialogTurnRequest { @@ -751,6 +790,14 @@ pub struct SteerDialogTurnRequest { /// Original user text for UI rendering (defaults to `content`). #[serde(default)] pub display_content: Option, + /// Images attached to the steering message. Same shape the composer sends + /// when it starts a turn — a message keeps its attachments whether it is + /// submitted at a turn boundary or injected into a running turn. + #[serde(default)] + pub image_contexts: Option>, + /// Structured metadata carried with the steering message. + #[serde(default)] + pub user_message_metadata: Option, } #[derive(Debug, Serialize)] @@ -1856,14 +1903,110 @@ pub async fn update_session_permission_mode( request.include_internal, ) .await?; + let session_manager = coordinator.get_session_manager(); + let requested_turn_id = request + .turn_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); - coordinator - .get_session_manager() + session_manager .update_session_permission_mode(&session_id, mode) .await .map_err(|error| format!("Failed to update session permission mode: {error}"))?; + if let Some(turn_id) = requested_turn_id { + session_manager.clear_active_turn_permission_mode(&session_id, turn_id); + } - Ok(SessionPermissionModeResponse { mode }) + let active_turn_id = requested_turn_id.and_then(|turn_id| { + session_manager + .get_session(&session_id) + .filter(|session| { + matches!( + &session.state, + SessionState::Processing { current_turn_id, .. } if current_turn_id == turn_id + ) + }) + .map(|_| turn_id.to_string()) + }); + Ok(SessionPermissionModeResponse { + mode: session_manager.session_permission_mode(&session_id), + turn_mode: active_turn_id + .as_deref() + .and_then(|turn_id| session_manager.active_turn_permission_mode(&session_id, turn_id)), + active_turn_id, + }) +} + +/// Replaces or clears the temporary mode for one exact active turn. +/// +/// This is intentionally a distinct command from the session setter: a new +/// frontend talking to an older backend must fail loudly instead of having an +/// unknown scope field ignored and accidentally persisting a one-off choice. +#[tauri::command] +pub async fn update_active_turn_permission_mode( + runtime: State<'_, DesktopRuntimeContext>, + coordinator: State<'_, Arc>, + request: UpdateActiveTurnPermissionModeRequest, +) -> Result { + let session_id = request.session_id.trim().to_string(); + let turn_id = request.turn_id.trim().to_string(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + if turn_id.is_empty() { + return Err("turn_id is required".to_string()); + } + let mode = match request.mode.as_deref().map(str::trim) { + None | Some("") => None, + Some(value) => Some( + PermissionMode::parse(value) + .ok_or_else(|| format!("unsupported permission mode: {value}"))?, + ), + }; + + ensure_session_loaded_for_selector_update( + runtime.inner(), + &session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + request.include_internal, + ) + .await?; + + let session_manager = coordinator.get_session_manager(); + let is_active = match mode { + Some(turn_mode) => { + session_manager.set_active_turn_permission_mode(&session_id, &turn_id, turn_mode) + } + None => { + let is_active = session_manager + .get_session(&session_id) + .is_some_and(|session| { + matches!( + &session.state, + SessionState::Processing { current_turn_id, .. } + if current_turn_id == &turn_id + ) + }); + if is_active { + session_manager.clear_active_turn_permission_mode(&session_id, &turn_id); + } + is_active + } + }; + if !is_active { + return Err(format!( + "Turn is no longer active for this session: session_id={session_id}, turn_id={turn_id}" + )); + } + + Ok(SessionPermissionModeResponse { + mode: session_manager.session_permission_mode(&session_id), + turn_mode: session_manager.active_turn_permission_mode(&session_id, &turn_id), + active_turn_id: Some(turn_id), + }) } /// Reads the session's own permission mode selection. @@ -1889,10 +2032,29 @@ pub async fn get_session_permission_mode( ) .await?; + let session_manager = coordinator.get_session_manager(); + let requested_turn_id = request + .turn_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let active_turn_id = requested_turn_id.and_then(|turn_id| { + session_manager + .get_session(&session_id) + .filter(|session| { + matches!( + &session.state, + SessionState::Processing { current_turn_id, .. } if current_turn_id == turn_id + ) + }) + .map(|_| turn_id.to_string()) + }); Ok(SessionPermissionModeResponse { - mode: coordinator - .get_session_manager() - .session_permission_mode(&session_id), + mode: session_manager.session_permission_mode(&session_id), + turn_mode: active_turn_id + .as_deref() + .and_then(|turn_id| session_manager.active_turn_permission_mode(&session_id, turn_id)), + active_turn_id, }) } @@ -2739,6 +2901,97 @@ pub async fn cancel_dialog_turn( .map(|_| ()) } +/// Request a recoverable interruption for a native local dialog turn. The +/// command returns only after the old execution has settled and the +/// `dialog-turn-interrupted` event is safe to act on. +#[tauri::command] +pub async fn interrupt_dialog_turn( + runtime: State<'_, DesktopRuntimeContext>, + request: CancelDialogTurnRequest, +) -> Result<(), String> { + let interruption = runtime + .agent_runtime() + .interrupt_turn(AgentTurnInterruptionRequest { + session_id: request.session_id.clone(), + turn_id: request.dialog_turn_id.clone(), + source: Some(AgentSubmissionSource::DesktopUi), + wait_timeout_ms: Some(30_000), + }) + .await; + match interruption { + Ok(_) => Ok(()), + // Stop remains universally available. Unsupported recovery surfaces + // (external routes, Goal, remote, non-standard sessions) degrade to + // the existing hard cancellation and emit DialogTurnCancelled. + Err(RuntimeError::Port(error)) + if matches!(error.kind, PortErrorKind::InvalidRequest | PortErrorKind::Timeout) => runtime + .agent_runtime() + .cancel_turn(AgentTurnCancellationRequest { + session_id: request.session_id.clone(), + turn_id: Some(request.dialog_turn_id.clone()), + source: Some(AgentSubmissionSource::DesktopUi), + requester_session_id: None, + reason: Some(if error.kind == PortErrorKind::Timeout { + "recoverable interruption timed out".to_string() + } else { + "recoverable interruption unavailable".to_string() + }), + wait_timeout_ms: None, + cancel_descendants: true, + }) + .await + .map(|_| ()) + .map_err(|fallback_error| { + log::error!( + "Failed to cancel dialog turn after interruption was unavailable: session_id={}, dialog_turn_id={}, error={}", + request.session_id, + request.dialog_turn_id, + fallback_error + ); + format!("Failed to cancel dialog turn: {}", fallback_error.into_message()) + }), + Err(error) => { + log::error!( + "Failed to interrupt dialog turn: session_id={}, dialog_turn_id={}, error={}", + request.session_id, + request.dialog_turn_id, + error + ); + Err(format!("Failed to interrupt dialog turn: {}", error.into_message())) + } + } +} + +#[tauri::command] +pub async fn recover_interrupted_dialog_turn( + runtime: State<'_, DesktopRuntimeContext>, + request: RecoverInterruptedDialogTurnRequest, +) -> Result { + runtime + .agent_runtime() + .recover_interrupted_turn(AgentDialogTurnRecoveryRequest { + session_id: request.session_id.clone(), + turn_id: request.dialog_turn_id.clone(), + execution_generation: request.execution_generation, + workspace_path: request.workspace_path, + remote_connection_id: request.remote_connection_id, + remote_ssh_host: request.remote_ssh_host, + }) + .await + .map_err(|error| { + log::error!( + "Failed to recover interrupted dialog turn: session_id={}, dialog_turn_id={}, error={}", + request.session_id, + request.dialog_turn_id, + error + ); + format!( + "Failed to recover interrupted dialog turn: {}", + error.into_message() + ) + }) +} + #[tauri::command] pub async fn steer_dialog_turn( runtime: State<'_, DesktopRuntimeContext>, @@ -2749,13 +3002,24 @@ pub async fn steer_dialog_turn( dialog_turn_id, content, display_content, + image_contexts, + user_message_metadata, } = request; - let trimmed = content.trim(); - if trimmed.is_empty() { + let attachments: Vec = image_contexts + .unwrap_or_default() + .into_iter() + .map(desktop_image_attachment) + .collect(); + + // An image-only steering message is a real message; only a message with + // neither text nor attachments is empty. + if content.trim().is_empty() && attachments.is_empty() { return Err("Steering content cannot be empty".to_string()); } + let metadata = desktop_user_message_metadata(user_message_metadata); + let outcome = runtime .agent_runtime() .steer_dialog_turn(AgentDialogSteerRequest { @@ -2763,6 +3027,8 @@ pub async fn steer_dialog_turn( turn_id: dialog_turn_id, content, display_content, + attachments, + metadata, }) .await .map_err(|error| format!("Failed to steer dialog turn: {}", error.into_message()))?; @@ -4121,6 +4387,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: None, + recovery: None, + recovery_epoch: None, error: None, error_detail: None, status: TurnStatus::Completed, @@ -4205,6 +4473,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: None, + recovery: None, + recovery_epoch: None, error: None, error_detail: None, status: TurnStatus::Completed, @@ -4270,6 +4540,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: None, + recovery: None, + recovery_epoch: None, error: None, error_detail: None, status: TurnStatus::Completed, diff --git a/src/apps/desktop/src/api/browser_control_api.rs b/src/apps/desktop/src/api/browser_control_api.rs index 1a7731a4d5..797be73d6b 100644 --- a/src/apps/desktop/src/api/browser_control_api.rs +++ b/src/apps/desktop/src/api/browser_control_api.rs @@ -18,6 +18,70 @@ fn default_cdp_port() -> u16 { DEFAULT_CDP_PORT } +/// Reattach to a browser that is already running with remote debugging on. +/// +/// The browser remembers the remote debugging preference across its own +/// restarts, and it keeps an approved connection grant for as long as it stays +/// running — but BitFun's connection registry lives in this process, so every +/// BitFun restart otherwise leaves Settings reporting "not connected" until +/// something asks for the browser. Reattaching here restores that connection +/// without the user having to click anything. +/// +/// Opt-in, because the grant does not survive a browser restart: after one, +/// reattaching raises an approval dialog before the user has asked for the +/// browser at all. +/// +/// This never starts a browser and never opens a settings page: when there is +/// no live endpoint to reattach to, it does nothing and leaves the on-demand +/// path to handle it. +pub fn init_on_startup() { + tokio::spawn(async { + if !auto_connect_on_startup_enabled().await { + return; + } + let Ok(kind) = selected_browser_kind().await else { + return; + }; + let Some(endpoint) = BrowserLauncher::user_profile_debug_endpoint(&kind) else { + return; + }; + if CdpClient::browser_connection_for_kind(DEFAULT_CDP_PORT, &kind) + .await + .is_some() + { + return; + } + // A denial or an approval timeout is an ordinary outcome here, not an + // error worth surfacing: the user never asked for this connection. + match CdpClient::connect_user_profile_browser( + DEFAULT_CDP_PORT, + endpoint.port, + &kind, + &endpoint.web_socket_url, + ) + .await + { + Ok(_) => log::info!("Reattached to the running {} profile on startup", kind), + Err(error) => log::info!( + "Could not reattach to the running {} profile on startup: {}", + kind, + error + ), + } + }); +} + +async fn auto_connect_on_startup_enabled() -> bool { + let Ok(service) = get_global_config_service().await else { + return false; + }; + service + .get_config::(None) + .await + .map(|config| config.ai.browser_control_auto_connect_on_startup) + .unwrap_or(false) +} + async fn selected_browser_kind() -> Result { let config = get_global_config_service() .await @@ -91,6 +155,12 @@ pub async fn browser_control_list_browsers() -> Result, pub port: u16, @@ -103,11 +173,48 @@ pub async fn browser_control_get_status( request: BrowserControlStatusRequest, ) -> Result { let port = request.port; - let available = BrowserLauncher::is_cdp_available(port).await; let configured_kind = selected_browser_kind().await?; + let default_cdp_supported = BrowserLauncher::supports_default_cdp(&configured_kind); + // Probe the live endpoint once and answer both questions from it: whether + // the persistent setting is on, and whether there is something to attach to + // right now. The probe is a file read plus a short local TCP connect, so it + // never prompts the browser the way attaching does. + let user_profile_endpoint = BrowserLauncher::user_profile_debug_endpoint(&configured_kind); + let default_cdp_enabled = default_cdp_supported + && (user_profile_endpoint.is_some() + || BrowserLauncher::is_default_cdp_enabled(&configured_kind)); + let user_profile_connection = + CdpClient::browser_connection_for_kind(port, &configured_kind).await; + let legacy_version = + if user_profile_connection.is_none() && BrowserLauncher::is_cdp_available(port).await { + CdpClient::get_version(port).await.ok() + } else { + None + }; + // Chrome and Edge share the logical 9222 slot in Settings. Do not report + // the selected browser as connected merely because the other one owns a + // legacy fixed-port endpoint left from an earlier selection. + let legacy_matches_selection = legacy_version.as_ref().is_some_and(|version| { + let detected = version + .browser + .as_deref() + .and_then(BrowserLauncher::browser_kind_from_cdp_version); + match &configured_kind { + BrowserKind::Chrome | BrowserKind::Edge => { + detected.map(|kind| kind == configured_kind).unwrap_or(true) + } + _ => true, + } + }); + let available = user_profile_connection.is_some() || legacy_matches_selection; + let browser_ready = available || user_profile_endpoint.is_some(); let (version, page_count, actual_kind) = if available { - let ver_info = CdpClient::get_version(port).await.ok(); + let ver_info = if let Some(connection) = &user_profile_connection { + connection.client.browser_version().await.ok() + } else { + legacy_version + }; let ver = ver_info.as_ref().and_then(|v| v.browser.clone()); // Identify the actual browser from CDP version response. let kind = ver @@ -116,15 +223,17 @@ pub async fn browser_control_get_status( .unwrap_or_else(|| configured_kind.clone()); // Only count targets of type "page" (real browser tabs), // not service workers, browser targets, etc. - let pages = CdpClient::list_pages(port) - .await - .ok() - .map(|p| { - p.iter() - .filter(|t| t.page_type.as_deref() == Some("page")) - .count() - }) - .unwrap_or(0); + let pages = if let Some(connection) = &user_profile_connection { + connection.client.browser_pages().await.ok() + } else { + CdpClient::list_pages(port).await.ok() + } + .map(|p| { + p.iter() + .filter(|t| t.page_type.as_deref() == Some("page")) + .count() + }) + .unwrap_or(0); (ver, pages, kind) } else { (None, 0, configured_kind) @@ -132,6 +241,9 @@ pub async fn browser_control_get_status( Ok(BrowserControlStatusResponse { cdp_available: available, + default_cdp_supported, + default_cdp_enabled, + browser_ready, browser_kind: actual_kind.to_string(), browser_version: version, port, @@ -153,6 +265,10 @@ pub struct BrowserControlLaunchResponse { pub status: String, pub message: Option, pub browser_kind: String, + /// Remote debugging settings URL, sent when the user has to open it + /// themselves because the platform cannot open a `chrome://` URL for them. + #[serde(skip_serializing_if = "Option::is_none")] + pub setup_url: Option, } fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserControlLaunchResponse { @@ -162,18 +278,47 @@ fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserContro status: "already_connected".into(), message: None, browser_kind: kind.to_string(), + setup_url: None, }, LaunchResult::Launched => BrowserControlLaunchResponse { success: true, status: "launched".into(), message: None, browser_kind: kind.to_string(), + setup_url: None, + }, + LaunchResult::UserProfileReady { .. } => BrowserControlLaunchResponse { + success: false, + status: "user_profile_ready".into(), + message: None, + browser_kind: kind.to_string(), + setup_url: None, + }, + LaunchResult::UserProfileSetupRequired { + instructions, + setup_url, + opened, + .. + } => BrowserControlLaunchResponse { + success: false, + // The two cases need different guidance: one asks the user to + // finish on a page that is already in front of them, the other + // asks them to open that page first. + status: if opened { + "requires_user_profile_setup".into() + } else { + "requires_manual_user_profile_setup".into() + }, + message: Some(instructions), + browser_kind: kind.to_string(), + setup_url: Some(setup_url), }, LaunchResult::LaunchedButCdpNotReady { message, .. } => BrowserControlLaunchResponse { success: false, status: "cdp_not_ready".into(), message: Some(message), browser_kind: kind.to_string(), + setup_url: None, }, LaunchResult::BrowserRunningWithoutCdp { instructions, .. } => { BrowserControlLaunchResponse { @@ -181,11 +326,47 @@ fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserContro status: "needs_restart".into(), message: Some(instructions), browser_kind: kind.to_string(), + setup_url: None, } } } } +async fn complete_launch( + kind: &BrowserKind, + logical_port: u16, + result: LaunchResult, +) -> Result { + match result { + LaunchResult::UserProfileReady { endpoint } => { + let connection = CdpClient::connect_user_profile_browser( + logical_port, + endpoint.port, + kind, + &endpoint.web_socket_url, + ) + .await; + if let Err(error) = connection { + return Ok(BrowserControlLaunchResponse { + success: false, + status: "user_profile_connection_failed".into(), + message: Some(error.to_string()), + browser_kind: kind.to_string(), + setup_url: None, + }); + } + Ok(BrowserControlLaunchResponse { + success: true, + status: "connected_user_profile".into(), + message: None, + browser_kind: kind.to_string(), + setup_url: None, + }) + } + other => Ok(to_launch_response(kind, other)), + } +} + /// Launch the user's default browser with CDP debug port. #[tauri::command] pub async fn browser_control_launch( @@ -194,11 +375,64 @@ pub async fn browser_control_launch( let port = request.port; let kind = selected_browser_kind().await?; + if CdpClient::browser_connection_for_kind(port, &kind) + .await + .is_some() + { + return Ok(to_launch_response(&kind, LaunchResult::AlreadyConnected)); + } + + // The logical port is shared across browser choices. Drop only the lookup + // entry when the user switches browsers; any already-attached page session + // keeps its transport alive, but new actions cannot accidentally reuse it. + if CdpClient::browser_connection(port).await.is_some() { + CdpClient::remove_browser_connection(port).await; + } + let result = BrowserLauncher::launch_with_cdp(&kind, port) .await .map_err(|e| e.to_string())?; - Ok(to_launch_response(&kind, result)) + complete_launch(&kind, port, result).await +} + +/// Open the selected browser's persistent guarded-CDP setting and wait for the +/// user-owned consent toggle. Once enabled, immediately request and retain the +/// real-profile connection so the Settings action is one continuous flow. +#[tauri::command] +pub async fn browser_control_enable_default_cdp( + request: BrowserControlLaunchRequest, +) -> Result { + let port = request.port; + let kind = selected_browser_kind().await?; + + if !BrowserLauncher::supports_default_cdp(&kind) { + return Ok(BrowserControlLaunchResponse { + success: false, + status: "default_cdp_unsupported".into(), + message: Some(format!( + "{} does not expose a supported persistent guarded-CDP setting", + kind + )), + browser_kind: kind.to_string(), + setup_url: None, + }); + } + + if CdpClient::browser_connection_for_kind(port, &kind) + .await + .is_some() + { + return Ok(to_launch_response(&kind, LaunchResult::AlreadyConnected)); + } + if CdpClient::browser_connection(port).await.is_some() { + CdpClient::remove_browser_connection(port).await; + } + + let result = BrowserLauncher::enable_default_cdp(&kind, port) + .await + .map_err(|e| e.to_string())?; + complete_launch(&kind, port, result).await } /// Restart the user's default browser with CDP debug port enabled. @@ -213,19 +447,5 @@ pub async fn browser_control_restart_with_cdp( .await .map_err(|e| e.to_string())?; - Ok(to_launch_response(&kind, result)) -} - -/// Create a macOS .app wrapper for the browser with CDP enabled. -#[tauri::command] -pub async fn browser_control_create_launcher() -> Result { - #[cfg(target_os = "macos")] - { - let kind = selected_browser_kind().await?; - BrowserLauncher::create_cdp_launcher_app(&kind, DEFAULT_CDP_PORT).map_err(|e| e.to_string()) - } - #[cfg(not(target_os = "macos"))] - { - Err("CDP launcher app creation is only supported on macOS".into()) - } + complete_launch(&kind, port, result).await } diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 70dbeeaa36..fc8ed169f3 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -16,20 +16,19 @@ use crate::api::search_api::{ use crate::api::workspace_activation::spawn_workspace_background_warmup; use crate::startup_trace::DesktopStartupTrace; use bitfun_core::infrastructure::{ - BatchedFileSearchProgressSink, FileSearchOutcome, FileSearchProgressSink, FileSearchResult, - FileSearchResultGroup, FileTreeNode, SearchMatchType, + BatchedFileSearchProgressSink, FileSearchResult, FileSearchResultGroup, FileTreeNode, + SearchMatchType, }; use bitfun_core::service::file_watch; use bitfun_core::service::remote_ssh::get_remote_workspace_manager; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::{ - shell_quote_posix, RemoteDirEntry, RemoteFileService, RemoteWorkspaceEntry, + search_remote_file_names, shell_quote_posix, RemoteFileNameSearch, }; use bitfun_core::service::workspace::{ ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions, }; use log::{debug, error, info, warn}; -use regex::Regex; use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -598,6 +597,8 @@ pub struct SearchFilenamesRequest { pub root_path: String, pub pattern: String, #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] pub search_id: Option, #[serde(default)] pub case_sensitive: bool, @@ -611,6 +612,38 @@ pub struct SearchFilenamesRequest { pub include_directories: bool, } +#[cfg(test)] +mod search_filenames_request_tests { + use super::SearchFilenamesRequest; + + #[test] + fn legacy_payload_without_remote_scope_remains_readable() { + let request: SearchFilenamesRequest = serde_json::from_value(serde_json::json!({ + "rootPath": "/workspace", + "pattern": "src" + })) + .expect("deserialize legacy filename search request"); + + assert_eq!(request.remote_connection_id, None); + assert!(request.include_directories); + } + + #[test] + fn remote_scope_is_deserialized_when_supplied() { + let request: SearchFilenamesRequest = serde_json::from_value(serde_json::json!({ + "rootPath": "/workspace", + "pattern": "src", + "remoteConnectionId": "remote-connection-1" + })) + .expect("deserialize scoped filename search request"); + + assert_eq!( + request.remote_connection_id.as_deref(), + Some("remote-connection-1") + ); + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchFileContentsRequest { @@ -648,208 +681,6 @@ fn resolve_search_limit(requested: Option, fallback: usize) -> usize { .clamp(1, HARD_MAX_SEARCH_RESULTS) } -fn compile_filename_search_regex( - pattern: &str, - case_sensitive: bool, - use_regex: bool, - whole_word: bool, -) -> Result { - let mut pattern = if use_regex { - pattern.to_string() - } else { - regex::escape(pattern) - }; - - if whole_word { - pattern = format!(r"\b(?:{})\b", pattern); - } - - if !case_sensitive { - pattern = format!("(?i){}", pattern); - } - - Regex::new(&pattern).map_err(|error| format!("Invalid search pattern: {}", error)) -} - -fn should_skip_remote_search_directory(name: &str) -> bool { - matches!( - name, - ".git" - | ".svn" - | ".hg" - | "node_modules" - | "target" - | "dist" - | "build" - | ".next" - | ".nuxt" - | ".cache" - | ".turbo" - ) -} - -fn should_skip_remote_search_file(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - matches!( - lower.rsplit_once('.').map(|(_, ext)| ext), - Some( - "png" - | "jpg" - | "jpeg" - | "gif" - | "webp" - | "ico" - | "pdf" - | "zip" - | "tar" - | "gz" - | "rar" - | "7z" - | "exe" - | "dll" - | "so" - | "dylib" - ) - ) -} - -fn remote_filename_search_result(entry: &RemoteDirEntry) -> FileSearchResult { - FileSearchResult { - path: entry.path.clone(), - name: entry.name.clone(), - is_directory: entry.is_dir, - match_type: SearchMatchType::FileName, - line_number: None, - matched_content: None, - preview_before: None, - preview_inside: None, - preview_after: None, - } -} - -struct RemoteFilenameSearch { - remote_fs: RemoteFileService, - entry: RemoteWorkspaceEntry, - root_path: String, - pattern: String, - case_sensitive: bool, - use_regex: bool, - whole_word: bool, - include_directories: bool, - limit: usize, - cancel_flag: Option>, - progress_sink: Option>, -} - -async fn search_remote_file_names_with_progress( - search: RemoteFilenameSearch, -) -> Result { - let RemoteFilenameSearch { - remote_fs, - entry, - root_path, - pattern, - case_sensitive, - use_regex, - whole_word, - include_directories, - limit, - cancel_flag, - progress_sink, - } = search; - let matcher = compile_filename_search_regex(&pattern, case_sensitive, use_regex, whole_word)?; - let mut stack = vec![root_path]; - let mut results = Vec::new(); - let mut truncated = false; - - while let Some(directory) = stack.pop() { - if cancel_flag - .as_ref() - .is_some_and(|flag| flag.load(Ordering::Relaxed)) - { - break; - } - - let mut entries = remote_fs - .read_dir(&entry.connection_id, &directory) - .await - .map_err(|error| format!("Failed to read remote directory: {}", error))?; - entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), - }); - - for child in entries { - if cancel_flag - .as_ref() - .is_some_and(|flag| flag.load(Ordering::Relaxed)) - { - break; - } - - if child.is_dir { - if should_skip_remote_search_directory(&child.name) { - continue; - } - - if include_directories && matcher.is_match(&child.name) { - let result = remote_filename_search_result(&child); - if let Some(sink) = progress_sink.as_ref() { - sink.report(FileSearchResultGroup { - path: result.path.clone(), - name: result.name.clone(), - is_directory: result.is_directory, - file_name_match: Some(result.clone()), - content_matches: Vec::new(), - }); - } - results.push(result); - if results.len() >= limit { - truncated = true; - break; - } - } - - stack.push(child.path); - continue; - } - - if !child.is_file || should_skip_remote_search_file(&child.name) { - continue; - } - - if matcher.is_match(&child.name) { - let result = remote_filename_search_result(&child); - if let Some(sink) = progress_sink.as_ref() { - sink.report(FileSearchResultGroup { - path: result.path.clone(), - name: result.name.clone(), - is_directory: result.is_directory, - file_name_match: Some(result.clone()), - content_matches: Vec::new(), - }); - } - results.push(result); - if results.len() >= limit { - truncated = true; - break; - } - } - } - - if truncated { - break; - } - } - - if let Some(sink) = progress_sink.as_ref() { - sink.flush(); - } - - Ok(FileSearchOutcome { results, truncated }) -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenameFileRequest { @@ -5077,14 +4908,20 @@ pub async fn search_filenames( include_directories: request.include_directories, }; - let result = match resolve_desktop_path_target(&state, &request.root_path, None).await { + let result = match resolve_desktop_path_target( + &state, + &request.root_path, + request.remote_connection_id.as_deref(), + ) + .await + { Ok(DesktopPathTarget::Remote { requested_path, entry, }) => match state.get_remote_file_service_async().await { - Ok(remote_fs) => search_remote_file_names_with_progress(RemoteFilenameSearch { + Ok(remote_fs) => search_remote_file_names(RemoteFileNameSearch { remote_fs, - entry, + workspace: entry, root_path: requested_path, pattern: request.pattern.clone(), case_sensitive: request.case_sensitive, @@ -5222,27 +5059,32 @@ pub async fn start_search_filenames_stream( include_directories: request.include_directories, }; - let remote_search_target = - match resolve_desktop_path_target(&state, &request.root_path, None).await { - Ok(DesktopPathTarget::Remote { - requested_path, - entry, - }) => { - let remote_fs = match state.get_remote_file_service_async().await { - Ok(remote_fs) => remote_fs, - Err(error) => { - unregister_search(&state, Some(&search_id)); - return Err(format!("Remote file service not available: {}", error)); - } - }; - Some((remote_fs, entry, requested_path)) - } - Ok(DesktopPathTarget::Local { .. }) => None, - Err(error) => { - unregister_search(&state, Some(&search_id)); - return Err(error); - } - }; + let remote_search_target = match resolve_desktop_path_target( + &state, + &request.root_path, + request.remote_connection_id.as_deref(), + ) + .await + { + Ok(DesktopPathTarget::Remote { + requested_path, + entry, + }) => { + let remote_fs = match state.get_remote_file_service_async().await { + Ok(remote_fs) => remote_fs, + Err(error) => { + unregister_search(&state, Some(&search_id)); + return Err(format!("Remote file service not available: {}", error)); + } + }; + Some((remote_fs, entry, requested_path)) + } + Ok(DesktopPathTarget::Local { .. }) => None, + Err(error) => { + unregister_search(&state, Some(&search_id)); + return Err(error); + } + }; let filesystem_service = state.filesystem_service.clone(); let active_searches = state.active_searches.clone(); @@ -5270,9 +5112,9 @@ pub async fn start_search_filenames_stream( tokio::spawn(async move { let result = if let Some((remote_fs, entry, requested_path)) = remote_search_target { - search_remote_file_names_with_progress(RemoteFilenameSearch { + search_remote_file_names(RemoteFileNameSearch { remote_fs, - entry, + workspace: entry, root_path: requested_path, pattern: pattern.clone(), case_sensitive, @@ -5570,6 +5412,13 @@ pub async fn get_ai_model_catalog() -> Result bitfun_core_types::ReasoningCatalogProjection { + bitfun_core::project_ai_model_reasoning_catalog(request).await +} + #[tauri::command] pub async fn get_models_dev_catalog_status() -> bitfun_core_types::ModelsDevCatalogStatus { bitfun_core::get_models_dev_catalog_status().await diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 0890159bac..9288eb1f25 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -7,17 +7,18 @@ use bitfun_core::external_sources::{ external_source_snapshot, get_external_source_control_snapshot as core_get_external_source_control_snapshot, native_prompt_command_conflicts, set_external_mcp_server_decision, - set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_subagent_model_binding, + set_external_mcp_servers_enabled, set_external_prompt_command_conflict_choice, + set_external_source_enabled, set_external_subagent_activation, + set_external_subagent_model_binding, set_external_subagents_enabled, set_external_tool_conflict_choice, set_external_tool_target_decision, - set_native_prompt_command_conflict_choice, unacknowledged_external_ecosystems, - update_external_integration_policy, workspace_reference_snapshot, - ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, - ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, - ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSourceSurfaceSnapshotV1, - ExternalSubagentModelBindingTarget, NativePromptCommandConflictSnapshot, - NativePromptCommandDescriptor, PromptCommandInvocationOutcome, - PromptCommandShellReviewDecision, + set_external_tool_targets_enabled, set_native_prompt_command_conflict_choice, + unacknowledged_external_ecosystems, update_external_integration_policy, + workspace_reference_snapshot, ExternalIntegrationPolicyMutation, + ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + ExternalSourceSurfaceSnapshotV1, ExternalSubagentModelBindingTarget, + NativePromptCommandConflictSnapshot, NativePromptCommandDescriptor, + PromptCommandInvocationOutcome, PromptCommandShellReviewDecision, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::workspace_state::{ @@ -154,6 +155,23 @@ pub struct SetExternalToolTargetDecisionRequest { pub expected_preference_revision: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolDecisionRef { + pub approval_key: String, + pub decision_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalToolTargetsEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_catalog_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalToolConflictChoiceRequest { @@ -174,6 +192,23 @@ pub struct SetExternalSubagentActivationRequest { pub decision_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalCandidateDecisionRef { + pub candidate_id: String, + pub decision_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalSubagentsEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_subagent_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSubagentModelBindingRequest { @@ -207,6 +242,16 @@ pub struct SetExternalMcpServerDecisionRequest { pub expected_preference_revision: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalMcpServersEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_mcp_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ChooseExternalMcpConflictRequest { @@ -553,6 +598,27 @@ pub async fn set_external_tool_target_decision_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_tool_targets_enabled_command( + request: SetExternalToolTargetsEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_tool_targets_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.approval_key, decision.decision_key)) + .collect(), + request.enabled, + request.expected_catalog_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_tool_conflict_choice_command( request: SetExternalToolConflictChoiceRequest, @@ -587,6 +653,27 @@ pub async fn set_external_subagent_activation_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_subagents_enabled_command( + request: SetExternalSubagentsEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_subagents_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.candidate_id, decision.decision_key)) + .collect(), + request.enabled, + request.expected_subagent_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_subagent_model_binding_command( request: SetExternalSubagentModelBindingRequest, @@ -640,6 +727,27 @@ pub async fn set_external_mcp_server_decision_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_mcp_servers_enabled_command( + request: SetExternalMcpServersEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_mcp_servers_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.candidate_id, decision.decision_key)) + .collect(), + request.enabled, + request.expected_mcp_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn choose_external_mcp_conflict_command( request: ChooseExternalMcpConflictRequest, diff --git a/src/apps/desktop/src/api/lsp_workspace_api.rs b/src/apps/desktop/src/api/lsp_workspace_api.rs index bf13e53e77..b16f154fb2 100644 --- a/src/apps/desktop/src/api/lsp_workspace_api.rs +++ b/src/apps/desktop/src/api/lsp_workspace_api.rs @@ -6,12 +6,11 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use bitfun_core::infrastructure::events::TransportEmitter; use bitfun_core::service::lsp::types::CompletionItem; use bitfun_core::service::lsp::{ close_workspace, get_workspace_manager, open_workspace_with_emitter, ServerState, }; -use bitfun_transport::TauriTransportAdapter; +use bitfun_transport::{TauriTransportAdapter, TransportEmitter}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 465b009951..61a85c19df 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -55,7 +55,6 @@ pub mod skill_api; pub mod snapshot_service; pub mod speech_api; pub mod ssh_api; -pub mod startchat_agent_api; pub mod storage_commands; pub mod subagent_api; pub mod system_api; diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 598f9fa52a..b7fd4b6f1e 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -123,12 +123,15 @@ async fn lookup_remote_entry_for_path( } let manager = get_remote_workspace_manager()?; + if let Some(connection_id) = request_preferred { + return manager.lookup_scoped_connection(path, connection_id).await; + } + let legacy = app_state .get_remote_workspace_async() .await .map(|workspace| workspace.connection_id); - let preferred = request_preferred.map(|s| s.to_string()).or(legacy); - manager.lookup_connection(path, preferred.as_deref()).await + manager.lookup_connection(path, legacy.as_deref()).await } fn should_force_local_assistant_path( @@ -152,8 +155,11 @@ pub async fn resolve_desktop_path_target( }); } + let explicit_connection_id = preferred_remote_connection_id + .map(str::trim) + .filter(|value| !value.is_empty()); if let Some(entry) = - lookup_remote_entry_for_path(app_state, raw_path, preferred_remote_connection_id).await + lookup_remote_entry_for_path(app_state, raw_path, explicit_connection_id).await { return Ok(DesktopPathTarget::Remote { requested_path: raw_path.to_string(), @@ -161,6 +167,13 @@ pub async fn resolve_desktop_path_target( }); } + if let Some(connection_id) = explicit_connection_id { + return Err(format!( + "Remote workspace connection '{}' is unavailable or does not own path '{}'; local filesystem fallback was not attempted", + connection_id, raw_path + )); + } + Ok(DesktopPathTarget::Local { requested_path: raw_path.to_string(), resolved_path: PathBuf::from(raw_path), diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index bac4f20251..3054b596eb 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -119,6 +119,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", // One-click relay deploy SSHes from the controller to a user host @@ -129,6 +130,16 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "relay_deploy_cancel", "relay_deploy_register", "relay_deploy_verify", + // Speech capture and model files belong to the machine the user speaks at. + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", ]; static PENDING: OnceLock>>> = @@ -350,6 +361,7 @@ pub async fn peer_mode_ping() -> Result { .unwrap_or_else(|_| "unknown".to_string()), "capabilities": { "idempotent_dialog_submit": true, + "targeted_session_rollback": true, }, })) } @@ -449,7 +461,7 @@ mod tests { } #[tokio::test] - async fn peer_ping_advertises_idempotent_dialog_submission() { + async fn peer_ping_advertises_mutation_capabilities() { let value = peer_mode_ping().await.expect("peer ping"); assert_eq!( value @@ -457,6 +469,12 @@ mod tests { .and_then(Value::as_bool), Some(true) ); + assert_eq!( + value + .pointer("/capabilities/targeted_session_rollback") + .and_then(Value::as_bool), + Some(true) + ); } #[test] @@ -476,6 +494,30 @@ mod tests { } } + /// The controller-side FE deny list is an optimization, not the boundary. + /// A controller on an older build (or a non-FE controller) still reaches + /// this host, so every controller-owned command must be refused here too. + #[test] + fn controller_owned_capture_and_dispatch_commands_are_refused_on_the_peer() { + for command in [ + // Capture and model files belong to the machine the user speaks at. + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", + // Same controller-owned observer/credential family as the other + // dispatch verbs already denied here. + "dispatch_continue", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } + #[test] fn only_the_final_detach_drains_peer_permission_requests() { let mut state = control_state(&["controller-a", "controller-b"], &["request-1"]); diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 37ec27c71d..86bc51a02e 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -22,7 +22,7 @@ use bitfun_core::service::remote_connect::{ bot::{self, weixin, BotConfig}, lan, session_store, sync_state, AccountClient, AccountPairingVerification, AccountSession, ConnectionMethod, ConnectionResult, DelegatedIdentityAuthorization, DeviceIdentity, - PairingState, RemoteConnectConfig, RemoteConnectService, + PairingState, ProvisionedDeviceAuthorization, RemoteConnectConfig, RemoteConnectService, }; use bitfun_core::service::session::{DialogTurnData, SessionMetadata}; use bitfun_core::service::workspace::{get_global_workspace_service, WorkspaceKind}; @@ -82,6 +82,17 @@ static ACCOUNT_CONTEXT_TRANSITIONS: AtomicUsize = AtomicUsize::new(0); static DEVICE_ROUTING_LIFECYCLE_LOCK: tokio::sync::RwLock<()> = tokio::sync::RwLock::const_new(()); static DEVICE_ROUTING_CONNECTION_ID: AtomicU64 = AtomicU64::new(0); +/// Ceiling on device RPCs executing at once. +/// +/// RPCs run off the routing loop rather than on it, so without a bound a phone +/// that fans out a screenful of `list_sessions` would put all of them on the +/// webview bridge at once. The bound exists to keep that burst from crowding +/// out the next device's first request, not because concurrency is unsafe: +/// each RPC holds its own routing lease and answers its own correlation id. +const MAX_CONCURRENT_DEVICE_RPCS: usize = 8; +static DEVICE_RPC_SLOTS: tokio::sync::Semaphore = + tokio::sync::Semaphore::const_new(MAX_CONCURRENT_DEVICE_RPCS); + #[derive(Clone, Debug, Eq, PartialEq)] struct DeviceRoutingOwner { account_generation: u64, @@ -359,6 +370,37 @@ fn emit_account_event(event: &str, payload: serde_json::Value) { } } +/// Payload key carrying the device that produced a re-emitted Peer DeviceEvent. +/// +/// The controller re-emits peer events under their original event name so the +/// existing listeners keep working, which means a controller that is also +/// running its own local work would otherwise see two indistinguishable +/// streams on one bus. The frontend routes on this key and delivers an event +/// only to the device surface it belongs to. Keep in sync with +/// `src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts`. +pub const PEER_EVENT_SOURCE_KEY: &str = "__bitfunSourceDeviceId"; + +/// Wrapper key used when a peer payload is not a JSON object and therefore +/// cannot carry `PEER_EVENT_SOURCE_KEY` inline. +pub const PEER_EVENT_WRAPPED_PAYLOAD_KEY: &str = "__bitfunSourcePayload"; + +/// Tag a peer-originated event payload with the device that produced it. +fn tag_peer_event_source(payload: serde_json::Value, source_device_id: &str) -> serde_json::Value { + let source = serde_json::Value::String(source_device_id.to_string()); + match payload { + serde_json::Value::Object(mut map) => { + map.insert(PEER_EVENT_SOURCE_KEY.to_string(), source); + serde_json::Value::Object(map) + } + other => { + let mut map = serde_json::Map::new(); + map.insert(PEER_EVENT_SOURCE_KEY.to_string(), source); + map.insert(PEER_EVENT_WRAPPED_PAYLOAD_KEY.to_string(), other); + serde_json::Value::Object(map) + } + } +} + fn emit_device_presence(devices: &[(String, String)]) { let payload = serde_json::json!({ "devices": devices @@ -902,6 +944,7 @@ pub(crate) async fn provision_dispatch_account_device( &session, &identity.device_id, &identity.device_name, + "desktop", uuid::Uuid::new_v4(), ) .await @@ -1220,6 +1263,62 @@ async fn register_delegated_identity_providers() { }) .await; + // Room-channel provider that adds a keyboard-less device (a watch) to + // this account. Same lease discipline as delegation above; the errors + // are returned rather than swallowed because a provisioning failure is + // shown to someone standing there waiting for it. + let account_context = get_account_context().clone(); + service + .set_peer_device_provisioner(move |device_id, device_name, request_id| { + let account_context = account_context.clone(); + Box::pin(async move { + // Minted by the device being provisioned so a retry anywhere + // along the chain replays one idempotent relay request. + let request_id = uuid::Uuid::parse_str(&request_id) + .map_err(|_| "Request id must be a UUID".to_string())?; + let generation = account_context_generation(); + if !account_context_is_current(generation) { + return Err("Desktop account changed; try again".to_string()); + } + let account_lease = lock_account_sync(generation) + .await + .map_err(|_| "Desktop account changed; try again".to_string())?; + let context = account_context + .read() + .await + .clone() + .ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?; + if !account_context_matches(generation, &context.session.token).await { + return Err("Desktop account changed; try again".to_string()); + } + let provisioned = AccountClient::new() + .provision_device_token( + &context.relay_url, + &context.session, + &device_id, + &device_name, + "watch", + request_id, + ) + .await + .map_err(|e| { + log::warn!("Provision device token failed: {e}"); + format!("Could not add the device to your account: {e}") + })?; + if !account_context_matches(generation, &context.session.token).await { + return Err("Desktop account changed; try again".to_string()); + } + Ok(ProvisionedDeviceAuthorization::with_host_lease( + provisioned.token, + provisioned.user_id, + context.session.master_key, + provisioned.device_id, + account_lease, + )) + }) + }) + .await; + // Account-mode mobile pairing: QR prefill + password verification. register_account_pairing_context(service).await; @@ -2727,9 +2826,15 @@ pub async fn account_connect_devices() -> Result, String> break 'routing_events; }; // Controller receiving peer UI events — re-emit locally - // under the same event name so PeerDeviceTransport listen works. + // under the same event name so PeerDeviceTransport listen + // works. The source device is tagged onto the payload so a + // controller that is also running local work can route each + // stream to the right device surface. log::debug!("DeviceEvent from {source_device_id}: {event}"); - emit_account_event(&event, payload); + emit_account_event( + &event, + tag_peer_event_source(payload, &source_device_id), + ); } Ok(RemoteCommand::ExecuteOnDevice { session_id, @@ -2826,7 +2931,10 @@ pub async fn account_connect_devices() -> Result, String> } } Ok(cmd) if source_device_id == "rpc" => { - let Some(_routing_effect) = + // The lease is taken here, on the loop, so a + // retiring loop still notices it has been + // replaced and stops reading events at once. + let Some(routing_effect) = lock_current_device_routing(&event_owner).await else { break 'routing_events; @@ -2837,34 +2945,56 @@ pub async fn account_connect_devices() -> Result, String> log::info!( "RPC request received from relay: corr={correlation_id}" ); - let execution = execute_local_remote_command(&cmd).await; - if !device_routing_owner_is_current(&event_owner).await { - break 'routing_events; - } - match execution { - Ok(resp_value) => { - send_rpc_envelope( - &event_owner, - &event_session, - &correlation_id, - resp_value, - ) - .await; + // Spawned rather than awaited. Most commands + // are answered by the webview, which can take + // up to DEFAULT_INVOKE_TIMEOUT (120s) to reply; + // awaiting here meant one slow command stalled + // every device behind it, so a `ping` from the + // watch could take 40s to come back for no + // reason of its own. Each RPC carries its own + // correlation id, so nothing about the reply + // path depends on them finishing in order. + let rpc_owner = event_owner.clone(); + let rpc_session = event_session.clone(); + tokio::spawn(async move { + // Held for the whole call: teardown takes + // the write lease, so an in-flight RPC now + // keeps the connection from being replaced + // out from under its own reply. + let _routing_effect = routing_effect; + let Ok(_slot) = DEVICE_RPC_SLOTS.acquire().await else { + return; + }; + let execution = execute_local_remote_command(&cmd).await; + // Returning drops this reply only. The loop + // re-checks ownership at the top of every + // iteration, so a stale connection is still + // retired there — just not from in here. + if !device_routing_owner_is_current(&rpc_owner).await { + return; } - Err(e) => { - log::warn!("RPC: execute command failed: {e}"); - send_rpc_error( - &event_owner, - &event_session, - &correlation_id, - format!("RPC execute failed: {e}"), - ) - .await; + match execution { + Ok(resp_value) => { + send_rpc_envelope( + &rpc_owner, + &rpc_session, + &correlation_id, + resp_value, + ) + .await; + } + Err(e) => { + log::warn!("RPC: execute command failed: {e}"); + send_rpc_error( + &rpc_owner, + &rpc_session, + &correlation_id, + format!("RPC execute failed: {e}"), + ) + .await; + } } - } - if !device_routing_owner_is_current(&event_owner).await { - break 'routing_events; - } + }); } Ok(cmd) => { let _ = cmd; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 825ea77ef5..89912d6c40 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -228,7 +228,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("add_skill", RemoteWorkspacePolicy::LegacyUnaudited), - ("analyze_work_state", RemoteWorkspacePolicy::LegacyUnaudited), ( "apply_external_mcp_import_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -252,7 +251,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("archive_session", RemoteWorkspacePolicy::LegacyUnaudited), ( - "browser_control_create_launcher", + "browser_control_enable_default_cdp", RemoteWorkspacePolicy::LocalOnly, ), ( @@ -284,6 +283,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("cancel_dialog_turn", RemoteWorkspacePolicy::LegacyUnaudited), + ("interrupt_dialog_turn", RemoteWorkspacePolicy::LocalOnly), ( "cancel_insights_generation", RemoteWorkspacePolicy::LocalOnly, @@ -497,13 +497,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "expand_external_prompt_command_command", RemoteWorkspacePolicy::RemoteUnsupported, ), - ( - "explorer_get_children", - RemoteWorkspacePolicy::LegacyUnaudited, - ), + ("explorer_get_children", RemoteWorkspacePolicy::RemoteRouted), ( "explorer_get_children_paginated", - RemoteWorkspacePolicy::LegacyUnaudited, + RemoteWorkspacePolicy::RemoteRouted, ), ( "explorer_get_file_tree", @@ -531,10 +528,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "generate_commit_message", RemoteWorkspacePolicy::LegacyUnaudited, ), - ( - "generate_greeting_only", - RemoteWorkspacePolicy::LegacyUnaudited, - ), ("generate_insights", RemoteWorkspacePolicy::RemoteRouted), ( "generate_session_title", @@ -561,6 +554,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_ai_model_catalog", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "project_ai_model_reasoning_catalog", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "get_models_dev_catalog_status", RemoteWorkspacePolicy::LocalOnly, @@ -608,11 +605,11 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ( "get_directory_children", - RemoteWorkspacePolicy::LegacyUnaudited, + RemoteWorkspacePolicy::RemoteRouted, ), ( "get_directory_children_paginated", - RemoteWorkspacePolicy::LegacyUnaudited, + RemoteWorkspacePolicy::RemoteRouted, ), ( "get_external_hook_catalog", @@ -811,10 +808,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("get_tool_info", RemoteWorkspacePolicy::LegacyUnaudited), ("get_turn_files", RemoteWorkspacePolicy::LegacyUnaudited), ("get_watched_paths", RemoteWorkspacePolicy::LegacyUnaudited), - ( - "get_work_state_summary", - RemoteWorkspacePolicy::LegacyUnaudited, - ), ("git_add_files", RemoteWorkspacePolicy::RemoteRouted), ("git_add_worktree", RemoteWorkspacePolicy::RemoteUnsupported), ("git_checkout_branch", RemoteWorkspacePolicy::RemoteRouted), @@ -1400,10 +1393,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "probe_acp_client_requirements", RemoteWorkspacePolicy::LegacyUnaudited, ), - ( - "quick_analyze_work_state", - RemoteWorkspacePolicy::LegacyUnaudited, - ), ( "quick_commit_message", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1421,10 +1410,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("read_mcp_resource", RemoteWorkspacePolicy::LegacyUnaudited), ("record_file_change", RemoteWorkspacePolicy::LegacyUnaudited), - ( - "record_local_command_turn", - RemoteWorkspacePolicy::RemoteRouted, - ), ( "refresh_model_client", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1455,6 +1440,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "remove_project_permission_grant", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "recover_interrupted_dialog_turn", + RemoteWorkspacePolicy::LocalOnly, + ), ( "clear_project_permission_grants", RemoteWorkspacePolicy::WorkspaceAgnostic, @@ -1695,7 +1684,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("rollback_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ("rollback_session", RemoteWorkspacePolicy::RemoteUnsupported), - ("rollback_to_turn", RemoteWorkspacePolicy::RemoteUnsupported), + ( + "rollback_session_to_turn", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("run_init_agents_md", RemoteWorkspacePolicy::LegacyUnaudited), ("run_system_command", RemoteWorkspacePolicy::LegacyUnaudited), ( @@ -1733,7 +1725,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "search_file_contents", RemoteWorkspacePolicy::LegacyUnaudited, ), - ("search_filenames", RemoteWorkspacePolicy::LegacyUnaudited), + ("search_filenames", RemoteWorkspacePolicy::RemoteRouted), ("search_files", RemoteWorkspacePolicy::LegacyUnaudited), ( "search_referenceable_sessions", @@ -1782,6 +1774,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_mcp_server_decision_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_mcp_servers_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_source_conflict_choice_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -1802,10 +1798,18 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_tool_target_decision_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_tool_targets_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_subagent_activation_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_subagents_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_subagent_model_binding_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -1937,7 +1941,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ( "start_search_filenames_stream", - RemoteWorkspacePolicy::LegacyUnaudited, + RemoteWorkspacePolicy::RemoteRouted, ), ("start_subscription_login", RemoteWorkspacePolicy::LocalOnly), ("startup_window_control", RemoteWorkspacePolicy::LocalOnly), @@ -2010,6 +2014,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "update_app_status", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "update_active_turn_permission_mode", + RemoteWorkspacePolicy::RemoteRouted, + ), ("update_cron_job", RemoteWorkspacePolicy::LegacyUnaudited), ( "update_custom_agent", @@ -2200,7 +2208,7 @@ mod tests { #[test] fn complete_rollback_commands_explicitly_reject_remote_workspaces() { - for command in ["rollback_session", "rollback_to_turn"] { + for command in ["rollback_session", "rollback_session_to_turn"] { assert_eq!( remote_workspace_policy(command), Some(RemoteWorkspacePolicy::RemoteUnsupported), @@ -2277,7 +2285,6 @@ mod tests { "accept_session", "activate_session_goal", "add_skill", - "analyze_work_state", "apply_patch", "archive_all_sessions", "archive_session", @@ -2330,8 +2337,6 @@ mod tests { "editor_ai_stream", "ensure_coordinator_session", "execute_tool", - "explorer_get_children", - "explorer_get_children_paginated", "explorer_get_file_tree", "export_config", "export_diagnostics_bundle", @@ -2340,7 +2345,6 @@ mod tests { "fetch_mcp_app_resource", "fork_session", "generate_commit_message", - "generate_greeting_only", "generate_session_title", "get_acp_clients", "get_acp_session_commands", @@ -2358,8 +2362,6 @@ mod tests { "get_current_workspace", "get_custom_agent_detail", "get_default_review_team_definition", - "get_directory_children", - "get_directory_children_paginated", "get_file_change_history", "get_file_diff", "get_file_editor_sync_hash", @@ -2404,7 +2406,6 @@ mod tests { "get_tool_info", "get_turn_files", "get_watched_paths", - "get_work_state_summary", "grant_miniapp_path", "grant_miniapp_workspace", "import_agent_companion_pet_package", @@ -2518,7 +2519,6 @@ mod tests { "predownload_acp_client_adapter", "preview_commit_message", "probe_acp_client_requirements", - "quick_analyze_work_state", "quick_commit_message", "read_background_command_output", "read_file_content", @@ -2559,7 +2559,6 @@ mod tests { "save_session_turn", "scan_workspace_info", "search_file_contents", - "search_filenames", "search_files", "search_skill_market", "send_background_command_input", @@ -2580,7 +2579,6 @@ mod tests { "start_mcp_remote_oauth", "start_mcp_server", "start_search_file_contents_stream", - "start_search_filenames_stream", "steer_dialog_turn", "stop_acp_client", "stop_file_watch", diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index abacb34899..17623bea85 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -12,7 +12,7 @@ use bitfun_core::agentic::persistence::{SessionBranchResult, SessionMetadataPage use bitfun_core::service::remote_ssh::normalize_remote_workspace_path; use bitfun_core::service::session::{ DialogTurnData, SessionKind, SessionMetadata, SessionStatus, SessionTranscriptExport, - SessionTranscriptExportOptions, SessionTurnCatalog, + SessionTranscriptExportOptions, }; use bitfun_core::service::session_usage::SessionUsageReport; use bitfun_core::service::workspace::WorkspaceKind; @@ -89,25 +89,6 @@ pub struct SaveSessionTurnRequest { pub remote_ssh_host: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RecordLocalCommandTurnRequest { - pub turn_data: DialogTurnData, - pub workspace_path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_connection_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_ssh_host: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RecordLocalCommandTurnResponse { - pub turn_id: String, - pub storage_turn_index: usize, - pub total_turn_count: usize, - pub turn_catalog: SessionTurnCatalog, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveSessionMetadataRequest { pub metadata: SessionMetadata, @@ -502,36 +483,6 @@ pub async fn save_session_turn( Ok(()) } -#[tauri::command] -pub async fn record_local_command_turn( - request: RecordLocalCommandTurnRequest, - runtime: State<'_, DesktopRuntimeContext>, -) -> Result { - let recorded = runtime - .session_application() - .record_local_command_turn( - desktop_session_scope( - request.workspace_path.clone(), - request.remote_connection_id, - request.remote_ssh_host, - ), - &request.turn_data, - ) - .await - .map_err(|error| format!("Failed to record local command turn: {error}"))?; - - crate::api::remote_connect_api::notify_session_changed( - &request.turn_data.session_id, - &request.workspace_path, - ); - Ok(RecordLocalCommandTurnResponse { - turn_id: recorded.turn_id, - storage_turn_index: recorded.storage_turn_index, - total_turn_count: recorded.total_turn_count, - turn_catalog: recorded.turn_catalog, - }) -} - #[tauri::command] pub async fn save_session_metadata( request: SaveSessionMetadataRequest, diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 6daa965c11..6b07272d06 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -9,15 +9,10 @@ use bitfun_core::service::snapshot::{ initialize_snapshot_manager_for_workspace, open_snapshot_manager_for_view, FileChangeEntry, OperationType, SnapshotConfig, SnapshotManager, }; -#[cfg(test)] -use bitfun_runtime_ports::LocalWorkspaceSnapshotSessionRequest; -use bitfun_runtime_ports::{ - LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, - SessionStoragePathRequest, -}; +use bitfun_runtime_ports::SessionStoragePathRequest; use log::{info, warn}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeSet, HashSet}; +use std::collections::BTreeSet; use std::{path::PathBuf, sync::Arc}; use tauri::{AppHandle, Emitter, State}; @@ -98,20 +93,6 @@ pub struct RollbackSessionRequest { pub remote_scope: SnapshotRemoteScope, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RollbackTurnRequest { - #[serde(alias = "sessionId")] - pub session_id: String, - #[serde(alias = "turnIndex")] - pub turn_index: usize, - #[serde(default)] - pub delete_turns: bool, - #[serde(alias = "workspacePath")] - pub workspace_path: String, - #[serde(flatten)] - pub remote_scope: SnapshotRemoteScope, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AcceptSessionRequest { #[serde(alias = "sessionId")] @@ -318,86 +299,6 @@ async fn resolve_workspace_dir(workspace_path: &str) -> Result Ok(workspace_dir) } -fn local_snapshot_command_error(operation: &str, workspace_path: &str, error: PortError) -> String { - if error.kind == PortErrorKind::NotAvailable { - format!( - "Failed to initialize snapshot system for workspace {}: {}", - workspace_path, error.message - ) - } else { - format!("Failed to {operation}: {}", error.message) - } -} - -async fn rollback_local_workspace_files( - port: &dyn LocalWorkspaceSnapshotPort, - workspace_path: PathBuf, - workspace_display: &str, - session_id: String, - turn_index: usize, -) -> Result, String> { - let restored_files = port - .rollback_workspace_files_to_turn(LocalWorkspaceSnapshotTurnRequest { - workspace_path, - session_id, - turn_index, - }) - .await - .map_err(|error| local_snapshot_command_error("rollback turn", workspace_display, error))?; - Ok(restored_files - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect()) -} - -#[cfg(test)] -async fn local_snapshot_session_files( - port: &dyn LocalWorkspaceSnapshotPort, - workspace_path: PathBuf, - workspace_display: &str, - session_id: String, -) -> Result, String> { - let files = port - .get_session_files(LocalWorkspaceSnapshotSessionRequest { - workspace_path, - session_id, - max_turn_exclusive: None, - }) - .await - .map_err(|error| { - local_snapshot_command_error("get session files", workspace_display, error) - })?; - Ok(files - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect()) -} - -#[cfg(test)] -async fn local_snapshot_session_stats( - port: &dyn LocalWorkspaceSnapshotPort, - workspace_path: PathBuf, - workspace_display: &str, - session_id: String, -) -> Result { - let stats = port - .get_session_stats(LocalWorkspaceSnapshotSessionRequest { - workspace_path, - session_id, - max_turn_exclusive: None, - }) - .await - .map_err(|error| { - local_snapshot_command_error("get session stats", workspace_display, error) - })?; - Ok(serde_json::json!({ - "session_id": stats.session_id, - "total_files": stats.total_files, - "total_turns": stats.total_turns, - "total_changes": stats.total_changes - })) -} - async fn ensure_snapshot_manager_ready_for( workspace_path: &str, caller: &str, @@ -496,8 +397,8 @@ async fn snapshot_manager_for_view( struct SnapshotHistoryMutation { _maintenance: CoreSessionMaintenancePermit, - mutation: CoreSessionMutationPermit, - storage_path: PathBuf, + _mutation: CoreSessionMutationPermit, + _storage_path: PathBuf, } async fn begin_snapshot_history_read( @@ -558,8 +459,8 @@ async fn begin_snapshot_history_mutation( })?; Ok(SnapshotHistoryMutation { _maintenance: maintenance, - mutation, - storage_path, + _mutation: mutation, + _storage_path: storage_path, }) } @@ -686,104 +587,21 @@ pub async fn rollback_session( } #[tauri::command] -pub async fn rollback_to_turn( - app_handle: AppHandle, +pub async fn rollback_session_to_turn( runtime: State<'_, DesktopRuntimeContext>, - request: RollbackTurnRequest, -) -> Result, String> { - ensure_complete_rollback_supported(&request.workspace_path, &request.remote_scope).await?; - ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; - let workspace_path = resolve_workspace_dir(&request.workspace_path).await?; - let compatibility = runtime.session_application().compatibility(); - let history_mutation = begin_snapshot_history_mutation( - runtime.inner(), - &request.workspace_path, - &request.session_id, - ) - .await?; - - let rolled_back_parent_turn_ids = if request.delete_turns { - compatibility - .validate_persisted_session_context_rollback( - &history_mutation.mutation, - request.turn_index, - ) - .await - .map_err(|error| format!("Failed to validate session rollback: {error}"))?; - compatibility - .load_persisted_session_turns_for_mutation(&history_mutation.mutation, None) - .await - .map_err(|error| format!("Failed to load turns before rollback: {error}"))? - .into_iter() - .filter(|turn| turn.turn_index >= request.turn_index) - .map(|turn| turn.turn_id) - .collect::>() - } else { - HashSet::new() + request: bitfun_runtime_ports::AgentSessionRollbackToTurnRequest, +) -> Result { + let remote_scope = SnapshotRemoteScope { + remote_connection_id: request.remote_connection_id.clone(), + remote_ssh_host: request.remote_ssh_host.clone(), }; - - let restored_files_str = rollback_local_workspace_files( - runtime.local_workspace_snapshot(), - workspace_path, - &request.workspace_path, - request.session_id.clone(), - request.turn_index, - ) - .await?; - - let deleted_turns_count = rolled_back_parent_turn_ids.len(); - if request.delete_turns { - compatibility - .rollback_persisted_session_context_to_turn_start( - &history_mutation.mutation, - request.turn_index, - ) - .await - .map_err(|error| { - format!( - "Workspace files were rolled back, but session history rollback failed. Reload the session before retrying: {error}" - ) - })?; - if !rolled_back_parent_turn_ids.is_empty() { - if let Err(error) = compatibility - .delete_hidden_subagent_sessions_for_parent_turns( - &history_mutation.storage_path, - &request.session_id, - &rolled_back_parent_turn_ids, - ) - .await - { - warn!( - "Failed to delete hidden subagent sessions during rollback: session_id={}, turn_index={}, error={}", - request.session_id, request.turn_index, error - ); - } - } - - let _ = app_handle.emit( - "conversation_turns_deleted", - serde_json::json!({ - "session_id": request.session_id, - "remaining_turns": request.turn_index, - "deleted_count": deleted_turns_count, - }), - ); - } - - drop(history_mutation); - - let _ = app_handle.emit( - "turn_rolled_back", - serde_json::json!({ - "session_id": request.session_id, - "turn_index": request.turn_index, - "files_count": restored_files_str.len(), - "deleted_turns": request.delete_turns, - "deleted_turns_count": deleted_turns_count, - }), - ); - - Ok(restored_files_str) + ensure_complete_rollback_supported(&request.workspace_path, &remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + runtime + .session_application() + .rollback_session_to_turn(request) + .await + .map_err(|error| error.to_string()) } #[tauri::command] @@ -1446,41 +1264,36 @@ pub async fn get_baseline_snapshot_diff( #[cfg(test)] mod tests { - use std::path::PathBuf; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use bitfun_runtime_ports::{ - LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, - LocalWorkspaceSnapshotStats, LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, - PortResult, - }; - use super::{ ensure_complete_rollback_supported, ensure_local_snapshot_mutation_path, - get_snapshot_manager_for_workspace, local_snapshot_command_error, - local_snapshot_session_files, local_snapshot_session_stats, rollback_local_workspace_files, - snapshot_manager_for_view, RollbackTurnRequest, SnapshotRemoteScope, + get_snapshot_manager_for_workspace, snapshot_manager_for_view, SnapshotRemoteScope, }; #[test] - fn snapshot_mutation_dto_preserves_structured_remote_facts() { - let request: RollbackTurnRequest = serde_json::from_value(serde_json::json!({ - "sessionId": "remote-session", - "turnIndex": 2, - "workspacePath": "/srv/project", - "remoteConnectionId": "ssh:user@example.com:22", - "remoteSshHost": "example.com" - })) - .expect("deserialize snapshot mutation scope"); - + fn targeted_rollback_request_preserves_stable_identity_and_remote_facts() { + let request: bitfun_runtime_ports::AgentSessionRollbackToTurnRequest = + serde_json::from_value(serde_json::json!({ + "sessionId": "remote-session", + "targetTurnId": "turn-7", + "expectedStorageTurnIndex": 7, + "expectedCatalogRevision": "catalog-3", + "workspacePath": "/srv/project", + "remoteConnectionId": "ssh:user@example.com:22", + "remoteSshHost": "example.com" + })) + .expect("deserialize targeted rollback request"); + + assert_eq!(request.target_turn_id, "turn-7"); + assert_eq!(request.expected_storage_turn_index, Some(7)); assert_eq!( - request.remote_scope.remote_connection_id.as_deref(), - Some("ssh:user@example.com:22") + request.expected_catalog_revision.as_deref(), + Some("catalog-3") ); assert_eq!( - request.remote_scope.remote_ssh_host.as_deref(), - Some("example.com") + request.remote_connection_id.as_deref(), + Some("ssh:user@example.com:22") ); + assert_eq!(request.remote_ssh_host.as_deref(), Some("example.com")); } #[tokio::test] @@ -1550,12 +1363,12 @@ mod tests { .split_once("pub async fn rollback_session") .expect("rollback_session remains present") .1 - .split_once("pub async fn rollback_to_turn") - .expect("rollback_to_turn remains present") + .split_once("pub async fn rollback_session_to_turn") + .expect("targeted rollback remains present") .0; - let rollback_to_turn = source - .split_once("pub async fn rollback_to_turn") - .expect("rollback_to_turn remains present") + let targeted_rollback = source + .split_once("pub async fn rollback_session_to_turn") + .expect("targeted rollback remains present") .1 .split_once("pub async fn accept_session") .expect("accept_session remains present") @@ -1573,16 +1386,15 @@ mod tests { assert_remote_guard_precedes(rollback_session, "ensure_local_runtime_ownership"); assert_remote_guard_precedes(rollback_session, "ensure_snapshot_manager_ready_for"); - assert_remote_guard_precedes(rollback_to_turn, "ensure_local_runtime_ownership"); - assert_remote_guard_precedes(rollback_to_turn, "begin_snapshot_history_mutation"); + assert_remote_guard_precedes(targeted_rollback, "ensure_local_runtime_ownership"); + assert_remote_guard_precedes(targeted_rollback, "rollback_session_to_turn"); } #[test] fn snapshot_mutators_share_session_revert_admission() { let source = include_str!("snapshot_service.rs"); for (command, next_command) in [ - ("rollback_session", "rollback_to_turn"), - ("rollback_to_turn", "accept_session"), + ("rollback_session", "rollback_session_to_turn"), ("accept_session", "accept_file"), ("accept_file", "reject_file"), ("reject_file", "get_session_files"), @@ -1602,6 +1414,22 @@ mod tests { ); } + let targeted_rollback = source + .split_once("pub async fn rollback_session_to_turn") + .expect("targeted rollback remains present") + .1 + .split_once("pub async fn accept_session") + .expect("accept_session remains present") + .0; + assert!( + targeted_rollback.contains(".rollback_session_to_turn(request)"), + "targeted rollback must delegate admission to the Agent Session transaction" + ); + assert!( + !targeted_rollback.contains("begin_snapshot_history_mutation"), + "targeted rollback must not reacquire the Session mutation owned by the Agent Session transaction" + ); + let record = source .split_once("pub async fn record_file_change") .expect("record_file_change remains present") @@ -1644,109 +1472,4 @@ mod tests { assert!(error.contains("snapshot_remote_workspace_unavailable")); assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); } - - #[derive(Default)] - struct RecordingSnapshotPort { - file_calls: AtomicUsize, - stats_calls: AtomicUsize, - rollback_calls: AtomicUsize, - } - - #[async_trait::async_trait] - impl LocalWorkspaceSnapshotPort for RecordingSnapshotPort { - async fn prepare_local_workspace(&self, _workspace_path: PathBuf) -> PortResult<()> { - Ok(()) - } - - async fn get_session_files( - &self, - _request: LocalWorkspaceSnapshotSessionRequest, - ) -> PortResult> { - self.file_calls.fetch_add(1, Ordering::SeqCst); - Ok(vec![PathBuf::from("changed.txt")]) - } - - async fn get_session_stats( - &self, - request: LocalWorkspaceSnapshotSessionRequest, - ) -> PortResult { - self.stats_calls.fetch_add(1, Ordering::SeqCst); - Ok(LocalWorkspaceSnapshotStats { - session_id: request.session_id, - total_files: 1, - total_turns: 2, - total_changes: 3, - }) - } - - async fn rollback_workspace_files_to_turn( - &self, - _request: LocalWorkspaceSnapshotTurnRequest, - ) -> PortResult> { - self.rollback_calls.fetch_add(1, Ordering::SeqCst); - Ok(vec![PathBuf::from("restored.txt")]) - } - } - - #[test] - fn local_snapshot_errors_preserve_desktop_initialization_and_operation_context() { - let initialization = local_snapshot_command_error( - "get session files", - "C:\\workspace", - PortError::new(PortErrorKind::NotAvailable, "backend unavailable"), - ); - assert_eq!( - initialization, - "Failed to initialize snapshot system for workspace C:\\workspace: backend unavailable" - ); - - let operation = local_snapshot_command_error( - "get session stats", - "C:\\workspace", - PortError::new(PortErrorKind::Backend, "stats failed"), - ); - assert_eq!(operation, "Failed to get session stats: stats failed"); - } - - #[tokio::test] - async fn local_snapshot_adapters_call_each_port_operation_once_and_keep_json_shape() { - let port = RecordingSnapshotPort::default(); - let workspace = PathBuf::from("workspace"); - - let files = local_snapshot_session_files( - &port, - workspace.clone(), - "workspace", - "session-1".to_string(), - ) - .await - .expect("file adapter should succeed"); - let stats = local_snapshot_session_stats( - &port, - workspace.clone(), - "workspace", - "session-1".to_string(), - ) - .await - .expect("stats adapter should succeed"); - let restored = rollback_local_workspace_files( - &port, - workspace, - "workspace", - "session-1".to_string(), - 4, - ) - .await - .expect("rollback adapter should succeed"); - - assert_eq!(port.file_calls.load(Ordering::SeqCst), 1); - assert_eq!(port.stats_calls.load(Ordering::SeqCst), 1); - assert_eq!(port.rollback_calls.load(Ordering::SeqCst), 1); - assert_eq!(files, vec!["changed.txt"]); - assert_eq!(restored, vec!["restored.txt"]); - assert_eq!(stats["session_id"], "session-1"); - assert_eq!(stats["total_files"], 1); - assert_eq!(stats["total_turns"], 2); - assert_eq!(stats["total_changes"], 3); - } } diff --git a/src/apps/desktop/src/api/startchat_agent_api.rs b/src/apps/desktop/src/api/startchat_agent_api.rs deleted file mode 100644 index 5405f64efb..0000000000 --- a/src/apps/desktop/src/api/startchat_agent_api.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Startchat Agent API - -use bitfun_core::function_agents::{ - startchat_func_agent::Language, StartchatFunctionAgent, WorkStateAnalysis, WorkStateOptions, -}; -use log::error; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use tauri::State; - -use super::app_state::AppState; - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AnalyzeWorkStateRequest { - pub repo_path: String, - pub options: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QuickAnalyzeRequest { - pub repo_path: String, - pub language: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GenerateGreetingRequest { - pub repo_path: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkStateSummaryResponse { - pub greeting_title: String, - pub current_state_summary: String, - pub has_git_changes: bool, - pub unstaged_files: u32, - pub unpushed_commits: u32, - pub predicted_actions_count: usize, -} - -#[tauri::command] -pub async fn analyze_work_state( - state: State<'_, AppState>, - request: AnalyzeWorkStateRequest, -) -> Result { - let agent = StartchatFunctionAgent::new(state.ai_client_factory.clone()); - let opts = request.options.unwrap_or_default(); - - agent - .analyze_work_state(Path::new(&request.repo_path), opts) - .await - .map_err(|e| { - error!( - "Work state analysis failed: repo_path={}, error={}", - request.repo_path, e - ); - e.to_string() - }) -} - -#[tauri::command] -pub async fn quick_analyze_work_state( - state: State<'_, AppState>, - request: QuickAnalyzeRequest, -) -> Result { - let agent = StartchatFunctionAgent::new(state.ai_client_factory.clone()); - let language = request.language.unwrap_or(Language::Chinese); - - agent - .quick_analyze(Path::new(&request.repo_path), language) - .await - .map_err(|e| { - error!( - "Quick work state analysis failed: repo_path={}, error={}", - request.repo_path, e - ); - e.to_string() - }) -} - -#[tauri::command] -pub async fn generate_greeting_only( - state: State<'_, AppState>, - request: GenerateGreetingRequest, -) -> Result { - let agent = StartchatFunctionAgent::new(state.ai_client_factory.clone()); - - agent - .generate_greeting_only(Path::new(&request.repo_path)) - .await - .map_err(|e| { - error!( - "Generate greeting failed: repo_path={}, error={}", - request.repo_path, e - ); - e.to_string() - }) -} - -#[tauri::command] -pub async fn get_work_state_summary( - state: State<'_, AppState>, - request: QuickAnalyzeRequest, -) -> Result { - let agent = StartchatFunctionAgent::new(state.ai_client_factory.clone()); - - let language = request.language.unwrap_or(Language::Chinese); - - let analysis = agent - .quick_analyze(Path::new(&request.repo_path), language) - .await - .map_err(|e| { - error!( - "Failed to get work state summary: repo_path={}, error={}", - request.repo_path, e - ); - e.to_string() - })?; - - let (unstaged_files, unpushed_commits, has_git_changes) = - if let Some(ref git) = analysis.current_state.git_state { - ( - git.unstaged_files + git.staged_files, - git.unpushed_commits, - git.unstaged_files > 0 || git.staged_files > 0 || git.unpushed_commits > 0, - ) - } else { - (0, 0, false) - }; - - Ok(WorkStateSummaryResponse { - greeting_title: analysis.greeting.title, - current_state_summary: analysis.current_state.summary, - has_git_changes, - unstaged_files, - unpushed_commits, - predicted_actions_count: analysis.predicted_actions.len(), - }) -} diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 3465aab81b..37fe9bd879 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -18,9 +18,14 @@ const UPDATE_PROGRESS_EVENT: &str = "bitfun-update-progress"; /// Updater origins, in configured (fallback) order. Kept in step with /// `scripts/desktop-tauri-build.mjs`, which bakes the same pair into the bundle. -const GITHUB_UPDATER_ENDPOINT: &str = - "https://github.com/GCWing/BitFun/releases/latest/download/latest.json"; -const OPENBITFUN_UPDATER_ENDPOINT: &str = "https://openbitfun.com/release/latest.json"; +const GITHUB_UPDATER_ENDPOINT: &str = match option_env!("BITFUN_UPDATER_PRIMARY_ENDPOINT") { + Some(endpoint) => endpoint, + None => "https://github.com/GCWing/BitFun/releases/latest/download/latest.json", +}; +const OPENBITFUN_UPDATER_ENDPOINT: &str = match option_env!("BITFUN_UPDATER_FALLBACK_ENDPOINT") { + Some(endpoint) => endpoint, + None => "https://openbitfun.com/release/latest.json", +}; /// Throughput probe settings, matching the CLI updater and the relay deploy /// script (`src/apps/cli/src/self_update.rs`, diff --git a/src/apps/desktop/src/computer_use/desktop_host/mod.rs b/src/apps/desktop/src/computer_use/desktop_host/mod.rs index 929303c55b..c3fcd69a68 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/mod.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/mod.rs @@ -36,6 +36,86 @@ const STALE_CAPTURE_TOOL_MESSAGE: &str = "Computer use refused: call **`screensh static SCREENSHOT_ID_COUNTER: AtomicU64 = AtomicU64::new(1); +/// How long `open_app` waits for a freshly activated app to show up in +/// LaunchServices before giving up on resolving its pid. +#[cfg(target_os = "macos")] +const OPEN_APP_SETTLE_MS: u64 = 3_000; +/// How long `open_app` waits for the app to put a window on screen. Cold +/// Electron launches routinely need several seconds; reporting `window_count: +/// 0` too early would send the agent down a false "app is broken" path. +#[cfg(target_os = "macos")] +const OPEN_APP_WINDOW_WAIT_MS: u64 = 8_000; +#[cfg(target_os = "macos")] +const OPEN_APP_POLL_INTERVAL_MS: u64 = 150; + +/// How long an `open_app` AppleScript may run before it is killed. +/// +/// `activate` sends an AppleEvent to the target app and waits for it to answer. +/// A hung or busy app simply does not answer, and macOS's default AppleEvent +/// timeout is **120 seconds** — during which `open_app` occupies a blocking +/// thread and the agent has no idea anything is wrong. An app that has not +/// acknowledged activation in a few seconds is not going to. +#[cfg(target_os = "macos")] +const OSASCRIPT_TIMEOUT_MS: u64 = 10_000; + +/// Run `osascript -e @@ -709,17 +724,6 @@ ${createWidgetAppearanceStaticShellCss()} return false; } return true; - }, - onNodeAdded: function (node) { - if ( - node && - node.nodeType === 1 && - node.tagName !== 'SCRIPT' && - node.tagName !== 'STYLE' - ) { - node.style.animation = 'bitfunWidgetFadeIn 0.18s ease both'; - } - return node; } }); } else { @@ -1044,7 +1048,7 @@ export const GenerativeWidgetFrame: React.FC = ({